Merge branch 'canary' into stable

This commit is contained in:
李华桥
2023-12-10 21:04:15 +08:00
399 changed files with 10365 additions and 4747 deletions
+3 -3
View File
@@ -41,11 +41,11 @@
"@opentelemetry/core": "^1.18.1",
"@opentelemetry/exporter-prometheus": "^0.45.1",
"@opentelemetry/exporter-zipkin": "^1.18.1",
"@opentelemetry/host-metrics": "^0.33.2",
"@opentelemetry/host-metrics": "^0.34.0",
"@opentelemetry/instrumentation": "^0.45.1",
"@opentelemetry/instrumentation-graphql": "^0.36.0",
"@opentelemetry/instrumentation-http": "^0.45.1",
"@opentelemetry/instrumentation-ioredis": "^0.35.3",
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
"@opentelemetry/instrumentation-socket.io": "^0.34.3",
"@opentelemetry/resources": "^1.18.1",
@@ -102,7 +102,7 @@
"@types/sinon": "^17.0.2",
"@types/supertest": "^2.0.16",
"@types/ws": "^8.5.10",
"ava": "^5.3.1",
"ava": "^6.0.0",
"c8": "^8.0.1",
"nodemon": "^3.0.1",
"sinon": "^17.0.1",
+2
View File
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { CacheModule } from './cache';
import { ConfigModule } from './config';
import { EventModule } from './event';
import { BusinessModules } from './modules';
import { AuthModule } from './modules/auth';
import { PrismaModule } from './prisma';
@@ -14,6 +15,7 @@ const BasicModules = [
PrismaModule,
ConfigModule.forRoot(),
CacheModule,
EventModule,
StorageModule.forRoot(),
SessionModule,
RateLimiterModule,
@@ -0,0 +1,23 @@
import type { Snapshot, Workspace } from '@prisma/client';
import { Flatten, Payload } from './types';
interface EventDefinitions {
workspace: {
deleted: Payload<Workspace['id']>;
};
snapshot: {
updated: Payload<
Pick<Snapshot, 'id' | 'workspaceId'> & {
previous: Pick<Snapshot, 'blob' | 'state' | 'updatedAt'>;
}
>;
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
};
}
export type EventKV = Flatten<EventDefinitions>;
export type Event = keyof EventKV;
export type EventPayload<E extends Event> = EventKV[E];
@@ -0,0 +1,45 @@
import { Global, Injectable, Module } from '@nestjs/common';
import {
EventEmitter2,
EventEmitterModule,
OnEvent as RawOnEvent,
} from '@nestjs/event-emitter';
import type { Event, EventPayload } from './events';
@Injectable()
export class EventEmitter {
constructor(private readonly emitter: EventEmitter2) {}
emit<E extends Event>(event: E, payload: EventPayload<E>) {
return this.emitter.emit(event, payload);
}
emitAsync<E extends Event>(event: E, payload: EventPayload<E>) {
return this.emitter.emitAsync(event, payload);
}
on<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
return this.emitter.on(event, handler);
}
once<E extends Event>(event: E, handler: (payload: EventPayload<E>) => void) {
return this.emitter.once(event, handler);
}
}
export const OnEvent = (
event: Event,
opts?: Parameters<typeof RawOnEvent>[1]
) => {
return RawOnEvent(event, opts);
};
@Global()
@Module({
imports: [EventEmitterModule.forRoot()],
providers: [EventEmitter],
exports: [EventEmitter],
})
export class EventModule {}
export { EventPayload };
@@ -0,0 +1,33 @@
export type Payload<T> = {
__payload: true;
data: T;
};
export type Join<A extends string, B extends string> = A extends ''
? B
: `${A}.${B}`;
export type PathType<T, Path extends string> = string extends Path
? unknown
: Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer R}`
? K extends keyof T
? PathType<T[K], R>
: unknown
: unknown;
export type Leaves<T, P extends string = ''> = T extends Payload<any>
? P
: T extends Record<string, any>
? {
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
}[keyof T]
: never;
export type Flatten<T> = Leaves<T> extends infer R
? {
// @ts-expect-error yo, ts can't make it
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
}
: never;
@@ -1,15 +1,15 @@
import { isDeepStrictEqual } from 'node:util';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { Snapshot } from '@prisma/client';
import { Config } from '../../config';
import { type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics';
import { PrismaService } from '../../prisma';
import { SubscriptionStatus } from '../payment/service';
import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';
@Injectable()
export class DocHistoryManager {
@@ -19,16 +19,38 @@ export class DocHistoryManager {
private readonly db: PrismaService
) {}
@OnEvent('doc:manager:snapshot:beforeUpdate')
async onDocUpdated(snapshot: Snapshot, forceCreate = false) {
const last = await this.last(snapshot.workspaceId, snapshot.id);
@OnEvent('workspace.deleted')
onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) {
return this.db.snapshotHistory.deleteMany({
where: {
workspaceId,
},
});
}
@OnEvent('snapshot.deleted')
onSnapshotDeleted({ workspaceId, id }: EventPayload<'snapshot.deleted'>) {
return this.db.snapshotHistory.deleteMany({
where: {
workspaceId,
id,
},
});
}
@OnEvent('snapshot.updated')
async onDocUpdated(
{ workspaceId, id, previous }: EventPayload<'snapshot.updated'>,
forceCreate = false
) {
const last = await this.last(workspaceId, id);
let shouldCreateHistory = false;
if (!last) {
// never created
shouldCreateHistory = true;
} else if (last.timestamp === snapshot.updatedAt) {
} else if (last.timestamp === previous.updatedAt) {
// no change
shouldCreateHistory = false;
} else if (
@@ -36,16 +58,23 @@ export class DocHistoryManager {
forceCreate ||
// last history created before interval in configs
last.timestamp.getTime() <
snapshot.updatedAt.getTime() - this.config.doc.history.interval
previous.updatedAt.getTime() - this.config.doc.history.interval
) {
shouldCreateHistory = true;
}
if (shouldCreateHistory) {
// skip the history recording when no actual update on snapshot happended
if (last && isDeepStrictEqual(last.state, snapshot.state)) {
if (last && isDeepStrictEqual(last.state, previous.state)) {
this.logger.debug(
`State matches, skip creating history record for ${snapshot.id} in workspace ${snapshot.workspaceId}`
`State matches, skip creating history record for ${id} in workspace ${workspaceId}`
);
return;
}
if (isEmptyBuffer(previous.blob)) {
this.logger.debug(
`Doc is empty, skip creating history record for ${id} in workspace ${workspaceId}`
);
return;
}
@@ -56,12 +85,12 @@ export class DocHistoryManager {
timestamp: true,
},
data: {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
timestamp: snapshot.updatedAt,
blob: snapshot.blob,
state: snapshot.state,
expiredAt: await this.getExpiredDateFromNow(snapshot.workspaceId),
workspaceId,
id,
timestamp: previous.updatedAt,
blob: previous.blob,
state: previous.state,
expiredAt: await this.getExpiredDateFromNow(workspaceId),
},
})
.catch(() => {
@@ -73,9 +102,7 @@ export class DocHistoryManager {
description: 'How many times the snapshot history created',
})
.add(1);
this.logger.log(
`History created for ${snapshot.id} in workspace ${snapshot.workspaceId}.`
);
this.logger.log(`History created for ${id} in workspace ${workspaceId}.`);
}
}
@@ -180,7 +207,7 @@ export class DocHistoryManager {
}
// save old snapshot as one history record
await this.onDocUpdated(oldSnapshot, true);
await this.onDocUpdated({ workspaceId, id, previous: oldSnapshot }, true);
// WARN:
// we should never do the snapshot updating in recovering,
// which is not the solution in CRDT.
@@ -5,12 +5,12 @@ import {
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Snapshot, Update } from '@prisma/client';
import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs';
import {
applyUpdate,
decodeStateVector,
Doc,
encodeStateAsUpdate,
encodeStateVector,
@@ -19,6 +19,7 @@ import {
import { Cache } from '../../cache';
import { Config } from '../../config';
import { EventEmitter, type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
@@ -40,7 +41,37 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
return compare(yBinary, yBinary2, true);
}
function isEmptyBuffer(buf: Buffer): boolean {
/**
* Detect whether rhs state is newer than lhs state.
*
* How could we tell a state is newer:
*
* i. if the state vector size is larger, it's newer
* ii. if the state vector size is same, compare each client's state
*/
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
const lhsVector = decodeStateVector(lhs);
const rhsVector = decodeStateVector(rhs);
if (lhsVector.size < rhsVector.size) {
return true;
}
for (const [client, state] of lhsVector) {
const rstate = rhsVector.get(client);
if (!rstate) {
return false;
}
if (state < rstate) {
return true;
}
}
return false;
}
export function isEmptyBuffer(buf: Buffer): boolean {
return (
buf.length === 0 ||
// 0x0000
@@ -71,7 +102,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private readonly db: PrismaService,
private readonly config: Config,
private readonly cache: Cache,
private readonly event: EventEmitter2
private readonly event: EventEmitter
) {}
onModuleInit() {
@@ -193,6 +224,33 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted(workspaceId: string) {
await this.db.snapshot.deleteMany({
where: {
workspaceId,
},
});
await this.db.update.deleteMany({
where: {
workspaceId,
},
});
}
@OnEvent('snapshot.deleted')
async onSnapshotDeleted({
id,
workspaceId,
}: EventPayload<'snapshot.deleted'>) {
await this.db.update.deleteMany({
where: {
id,
workspaceId,
},
});
}
/**
* add update to manager for later processing.
*/
@@ -374,23 +432,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
}
const { id, workspaceId } = candidate;
// acquire lock
const ok = await this.lockUpdatesForAutoSquash(workspaceId, id);
if (!ok) {
return;
}
try {
await this._get(workspaceId, id);
} catch (e) {
this.logger.error(
`Failed to apply updates for workspace: ${workspaceId}, guid: ${id}`
);
this.logger.error(e);
} finally {
await this.unlockUpdatesForAutoSquash(workspaceId, id);
}
await this.lockUpdatesForAutoSquash(workspaceId, id, async () => {
try {
await this._get(workspaceId, id);
} catch (e) {
this.logger.error(
`Failed to apply updates for workspace: ${workspaceId}, guid: ${id}`
);
this.logger.error(e);
}
});
}
private async getAutoSquashCandidate() {
@@ -414,34 +466,67 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
doc: Doc,
initialSeq?: number
) {
const blob = Buffer.from(encodeStateAsUpdate(doc));
const state = Buffer.from(encodeStateVector(doc));
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
const blob = Buffer.from(encodeStateAsUpdate(doc));
if (isEmptyBuffer(blob)) {
return;
}
if (isEmptyBuffer(blob)) {
return false;
}
await this.db.snapshot.upsert({
select: {
seq: true,
},
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
create: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
},
update: {
blob,
state,
},
const state = Buffer.from(encodeStateVector(doc));
return await this.db.$transaction(async db => {
const snapshot = await db.snapshot.findUnique({
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
});
// update
if (snapshot) {
// only update if state is newer
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
await db.snapshot.update({
select: {
seq: true,
},
where: {
id_workspaceId: {
workspaceId,
id: guid,
},
},
data: {
blob,
state,
},
});
return true;
} else {
return false;
}
} else {
// create
await db.snapshot.create({
select: {
seq: true,
},
data: {
id: guid,
workspaceId,
blob,
state,
seq: initialSeq,
},
});
return true;
}
});
});
}
@@ -480,25 +565,39 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
...updates.map(u => u.blob)
);
if (snapshot) {
this.event.emit('doc:manager:snapshot:beforeUpdate', snapshot);
}
await this.upsert(workspaceId, id, doc, last.seq);
this.logger.debug(
`Squashed ${updates.length} updates for ${id} in workspace ${workspaceId}`
);
await this.db.update.deleteMany({
where: {
if (snapshot) {
this.event.emit('snapshot.updated', {
id,
workspaceId,
seq: {
in: updates.map(u => u.seq),
previous: {
blob: snapshot.blob,
state: snapshot.state,
updatedAt: snapshot.updatedAt,
},
},
});
});
}
const done = await this.upsert(workspaceId, id, doc, last.seq);
if (done) {
this.logger.debug(
`Squashed ${updates.length} updates for ${id} in workspace ${workspaceId}`
);
await this.db.update.deleteMany({
where: {
id,
workspaceId,
seq: {
in: updates.map(u => u.seq),
},
},
});
await this.updateCachedUpdatesCount(workspaceId, id, -updates.length);
}
await this.updateCachedUpdatesCount(workspaceId, id, -updates.length);
return doc;
}
@@ -581,22 +680,44 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
return null;
}
private async lockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache.setnx(
private async doWithLock<T>(lock: string, job: () => Promise<T>) {
const acquired = await this.cache.setnx(lock, 1, {
ttl: 60 * 1000,
});
if (!acquired) {
return;
}
try {
return await job();
} finally {
await this.cache.delete(lock).catch(e => {
// safe, the lock will be expired when ttl ends
this.logger.error(`Failed to release lock ${lock}`, e);
});
}
}
private async lockUpdatesForAutoSquash<T>(
workspaceId: string,
guid: string,
job: () => Promise<T>
) {
return this.doWithLock(
`doc:manager:updates-lock:${workspaceId}::${guid}`,
1,
{
ttl: 60 * 1000,
}
job
);
}
private async unlockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache
.delete(`doc:manager:updates-lock:${workspaceId}::${guid}`)
.catch(e => {
// safe, the lock will be expired when ttl ends
this.logger.error('Failed to release updates lock', e);
});
async lockSnapshotForUpsert<T>(
workspaceId: string,
guid: string,
job: () => Promise<T>
) {
return this.doWithLock(
`doc:manager:snapshot-lock:${workspaceId}::${guid}`,
job
);
}
}
+1 -6
View File
@@ -1,5 +1,4 @@
import { DynamicModule, Type } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { GqlModule } from '../graphql.module';
@@ -11,11 +10,7 @@ import { SyncModule } from './sync';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
const BusinessModules: (Type | DynamicModule)[] = [
EventEmitterModule.forRoot({
global: true,
}),
];
const BusinessModules: (Type | DynamicModule)[] = [];
switch (SERVER_FLAVOR) {
case 'sync':
@@ -25,7 +25,6 @@ import {
EventError,
InternalError,
NotInWorkspaceError,
WorkspaceNotFoundError,
} from './error';
export const GatewayErrorWrapper = (): MethodDecorator => {
@@ -319,9 +318,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
if (!doc) {
return {
error: docId.isWorkspace
? new WorkspaceNotFoundError(workspaceId)
: new DocNotFoundError(workspaceId, docId.guid),
error: new DocNotFoundError(workspaceId, docId.guid),
};
}
@@ -33,6 +33,7 @@ import type {
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import { EventEmitter } from '../../event';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
@@ -146,6 +147,7 @@ export class WorkspaceResolver {
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly users: UsersService,
private readonly event: EventEmitter,
@Inject(StorageProvide) private readonly storage: Storage
) {}
@@ -308,22 +310,11 @@ export class WorkspaceResolver {
})
async createWorkspace(
@CurrentUser() user: UserType,
@Args({ name: 'init', type: () => GraphQLUpload })
update: FileUpload
// we no longer support init workspace with a preload file
// use sync system to uploading them once created
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
init: FileUpload | null
) {
// convert stream to buffer
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = update.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
});
stream.on('error', reject);
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
const workspace = await this.prisma.workspace.create({
data: {
public: false,
@@ -341,14 +332,31 @@ export class WorkspaceResolver {
},
});
if (buffer.length) {
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
if (init) {
// convert stream to buffer
const buffer = await new Promise<Buffer>(resolve => {
const stream = init.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
});
stream.on('error', () => {
resolve(Buffer.from([]));
});
stream.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
if (buffer.length) {
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
}
}
return workspace;
@@ -382,18 +390,7 @@ export class WorkspaceResolver {
},
});
await this.prisma.$transaction([
this.prisma.update.deleteMany({
where: {
workspaceId: id,
},
}),
this.prisma.snapshot.deleteMany({
where: {
workspaceId: id,
},
}),
]);
this.event.emit('workspace.deleted', id);
return true;
}
+1 -1
View File
@@ -292,7 +292,7 @@ type Mutation {
sendVerifyChangeEmail(token: String!, email: String!, callbackUrl: String!): Boolean!
"""Create a new workspace"""
createWorkspace(init: Upload!): WorkspaceType!
createWorkspace(init: Upload): WorkspaceType!
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
+78 -3
View File
@@ -1,15 +1,20 @@
import { mock } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { register } from 'prom-client';
import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import {
applyUpdate,
decodeStateVector,
Doc as YDoc,
encodeStateAsUpdate,
} from 'yjs';
import { CacheModule } from '../src/cache';
import { Config, ConfigModule } from '../src/config';
import { EventModule } from '../src/event';
import { DocManager, DocModule } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils';
@@ -19,7 +24,7 @@ const createModule = () => {
imports: [
PrismaModule,
CacheModule,
EventEmitterModule.forRoot(),
EventModule,
ConfigModule.forRoot(),
DocModule.forRoot(),
],
@@ -283,3 +288,73 @@ test('should throw if meet max retry times', async t => {
);
t.is(stub.callCount, 5);
});
test('should not update snapshot if state is outdated', async t => {
const db = m.get(PrismaService);
const manager = m.get(DocManager);
await db.snapshot.create({
data: {
id: '2',
workspaceId: '2',
blob: Buffer.from([0, 0]),
seq: 1,
},
});
const doc = new YDoc();
const text = doc.getText('content');
const updates: Buffer[] = [];
doc.on('update', update => {
updates.push(Buffer.from(update));
});
text.insert(0, 'hello');
text.insert(5, 'world');
text.insert(5, ' ');
await Promise.all(updates.map(update => manager.push('2', '2', update)));
const updateWith3Records = await manager.getUpdates('2', '2');
text.insert(11, '!');
await manager.push('2', '2', updates[3]);
const updateWith4Records = await manager.getUpdates('2', '2');
// Simulation:
// Node A get 3 updates and squash them at time 1, will finish at time 10
// Node B get 4 updates and squash them at time 3, will finish at time 8
// Node B finish the squash first, and update the snapshot
// Node A finish the squash later, and update the snapshot to an outdated state
// Time: ---------------------->
// A: ^get ^upsert
// B: ^get ^upsert
//
// We should avoid such situation
// @ts-expect-error private
await manager.squash(updateWith4Records, null);
// @ts-expect-error private
await manager.squash(updateWith3Records, null);
const result = await db.snapshot.findUnique({
where: {
id_workspaceId: {
id: '2',
workspaceId: '2',
},
},
});
if (!result) {
t.fail('snapshot not found');
return;
}
const state = decodeStateVector(result.state!);
t.is(state.get(doc.clientID), 12);
const d = new YDoc();
applyUpdate(d, result.blob!);
const dtext = d.getText('content');
t.is(dtext.toString(), 'hello world!');
});
+21 -40
View File
@@ -6,6 +6,7 @@ import test from 'ava';
import * as Sinon from 'sinon';
import { ConfigModule } from '../src/config';
import type { EventPayload } from '../src/event';
import { DocHistoryManager } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils';
@@ -41,21 +42,28 @@ test.afterEach(async () => {
const snapshot: Snapshot = {
workspaceId: '1',
id: 'doc1',
blob: Buffer.from([0, 0]),
state: Buffer.from([0, 0]),
blob: Buffer.from([1, 0]),
state: Buffer.from([0]),
seq: 0,
updatedAt: new Date(),
createdAt: new Date(),
};
function getEventData(
timestamp: Date = new Date()
): EventPayload<'snapshot.updated'> {
return {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
previous: { ...snapshot, updatedAt: timestamp },
};
}
test('should create doc history if never created before', async t => {
Sinon.stub(manager, 'last').resolves(null);
const timestamp = new Date();
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
await manager.onDocUpdated(getEventData(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
@@ -72,10 +80,7 @@ test('should not create history if timestamp equals to last record', async t =>
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({ timestamp, state: null });
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
await manager.onDocUpdated(getEventData(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
@@ -94,10 +99,7 @@ test('should not create history if state equals to last record', async t => {
state: snapshot.state,
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
await manager.onDocUpdated(getEventData(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
@@ -116,10 +118,7 @@ test('should not create history if time diff is less than interval config', asyn
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
await manager.onDocUpdated(getEventData(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
@@ -138,10 +137,7 @@ test('should create history if time diff is larger than interval config and stat
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
await manager.onDocUpdated(getEventData(timestamp));
const history = await db.snapshotHistory.findFirst({
where: {
@@ -160,13 +156,7 @@ test('should create history with force flag even if time diff in small', async t
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated(
{
...snapshot,
updatedAt: timestamp,
},
true
);
await manager.onDocUpdated(getEventData(timestamp), true);
const history = await db.snapshotHistory.findFirst({
where: {
@@ -224,13 +214,7 @@ test('should correctly list all history records', async t => {
test('should be able to get history data', async t => {
const timestamp = new Date();
await manager.onDocUpdated(
{
...snapshot,
updatedAt: timestamp,
},
true
);
await manager.onDocUpdated(getEventData(timestamp), true);
const history = await manager.get(
snapshot.workspaceId,
@@ -274,10 +258,7 @@ test('should be able to recover from history', async t => {
},
});
const history1Timestamp = snapshot.updatedAt.getTime() - 10;
await manager.onDocUpdated({
...snapshot,
updatedAt: new Date(history1Timestamp),
});
await manager.onDocUpdated(getEventData(new Date(history1Timestamp)));
await manager.recover(
snapshot.workspaceId,
+19 -16
View File
@@ -1,21 +1,6 @@
/* tslint:disable */
/* auto-generated by NAPI-RS */
/* eslint-disable */
/* auto-generated by NAPI-RS */
export function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
export function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
export interface Blob {
contentType: string
lastModified: string
size: number
data: Buffer
}
/**
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
* result binary.
*/
export function mergeUpdatesInApplyWay(updates: Array<Buffer>): Buffer
export class Storage {
/** Create a storage instance and establish connection to persist store. */
static connect(database: string, debugOnlyAutoMigrate?: boolean | undefined | null): Promise<Storage>
@@ -30,3 +15,21 @@ export class Storage {
/** Workspace size taken by blobs. */
blobsSize(workspaces: Array<string>): Promise<number>
}
export interface Blob {
contentType: string
lastModified: string
size: number
data: Buffer
}
/**
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
* result binary.
*/
export function mergeUpdatesInApplyWay(updates: Array<Buffer>): Buffer
export function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
export function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
+4 -5
View File
@@ -16,27 +16,26 @@
}
},
"napi": {
"name": "storage",
"binaryName": "storage",
"targets": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"aarch64-pc-windows-msvc",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-gnu",
"universal-apple-darwin"
"x86_64-unknown-linux-gnu"
]
},
"scripts": {
"test": "node --test ./__tests__/**/*.spec.js",
"build": "napi build --release --strip",
"build": "napi build --release --strip --no-const-enum",
"build:debug": "napi build",
"prepublishOnly": "napi prepublish -t npm",
"artifacts": "napi artifacts",
"version": "napi version"
},
"devDependencies": {
"@napi-rs/cli": "^2.16.5",
"@napi-rs/cli": "3.0.0-alpha.15",
"lib0": "^0.2.87",
"nx": "^17.1.3",
"nx-cloud": "^16.5.2",
+3 -4
View File
@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/global": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "0.34.6",
@@ -21,8 +21,7 @@
},
"peerDependencies": {
"@affine/templates": "workspace:*",
"@blocksuite/global": "0.0.0-20230409084303-221991d4-nightly",
"@toeverything/infra": "workspace:*"
"@blocksuite/global": "0.0.0-20230409084303-221991d4-nightly"
},
"dependencies": {
"lit": "^3.0.2"
+4 -6
View File
@@ -6,12 +6,9 @@ import { isDesktop, isServer } from './constant.js';
import { UaHelper } from './ua-helper.js';
export const blockSuiteFeatureFlags = z.object({
enable_set_remote_flag: z.boolean(),
enable_block_hub: z.boolean(),
enable_toggle_block: z.boolean(),
enable_bookmark_operation: z.boolean(),
enable_note_index: z.boolean(),
enable_transformer_clipboard: z.boolean(),
enable_expand_database_block: z.boolean(),
enable_bultin_ledits: z.boolean(),
});
export const runtimeFlagsSchema = z.object({
@@ -21,6 +18,7 @@ export const runtimeFlagsSchema = z.object({
enableBroadcastChannelProvider: z.boolean(),
enableDebugPage: z.boolean(),
changelogUrl: z.string(),
downloadUrl: z.string(),
// see: tools/workers
imageProxyUrl: z.string(),
enablePreloading: z.boolean(),
+1 -22
View File
@@ -1,5 +1,3 @@
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import type {
ActiveDocProvider,
PassiveDocProvider,
@@ -111,7 +109,7 @@ export const settingPanel = {
Export: 'export',
Sync: 'sync',
} as const;
export const settingPanelValues = [...Object.values(settingPanel)] as const;
export const settingPanelValues = Object.values(settingPanel);
export type SettingPanel = (typeof settingPanel)[keyof typeof settingPanel];
// built-in workspaces
@@ -134,18 +132,6 @@ type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = {
currentWorkspaceId: string;
};
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
rightSlot?: ReactNode;
currentEntry:
| {
subPath: WorkspaceSubPath;
}
| {
pageId: string;
};
};
type NewSettingProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
onDeleteLocalWorkspace: () => void;
@@ -161,18 +147,11 @@ type NewSettingProps<Flavour extends keyof WorkspaceRegistry> =
) => void;
};
type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
UIBaseProps<Flavour> & {
currentPageId: string;
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
};
interface FC<P> {
(props: P): ReactNode;
}
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
PageDetail: FC<PageDetailProps<Flavour>>;
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
Provider: FC<PropsWithChildren>;
LoginCard?: FC<object>;
-3
View File
@@ -7,9 +7,6 @@
"outDir": "lib"
},
"references": [
{
"path": "../infra"
},
{
"path": "../../../tests/fixtures"
}
+10 -8
View File
@@ -54,10 +54,12 @@
"dev": "vite build --watch"
},
"dependencies": {
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/sdk": "workspace:*",
"@blocksuite/blocks": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/global": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/blocks": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/global": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"jotai": "^2.5.1",
"jotai-effect": "^0.2.3",
"tinykeys": "^2.1.0",
@@ -66,22 +68,22 @@
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/editor": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/lit": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/lit": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/presets": "0.11.0-nightly-202312070955-2b5bb47",
"@testing-library/react": "^14.0.0",
"async-call-rpc": "^6.3.1",
"electron": "link:../../frontend/electron/node_modules/electron",
"nanoid": "^5.0.3",
"react": "^18.2.0",
"rxjs": "^7.8.1",
"vite": "^4.4.11",
"vite": "^5.0.6",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"yjs": "^13.6.10"
},
"peerDependencies": {
"@affine/templates": "*",
"@blocksuite/editor": "*",
"@blocksuite/presets": "*",
"async-call-rpc": "*",
"electron": "*",
"react": "*",
@@ -91,7 +93,7 @@
"@affine/templates": {
"optional": true
},
"@blocksuite/editor": {
"@blocksuite/presets": {
"optional": true
},
"async-call-rpc": {
+8
View File
@@ -0,0 +1,8 @@
import { atom } from 'jotai';
export const loadedPluginNameAtom = atom<string[]>([]);
export * from './layout';
export * from './root-store';
export * from './settings';
export * from './workspace';
@@ -1,34 +1,5 @@
import type { ExpectedLayout } from '@affine/sdk/entry';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { atom, createStore } from 'jotai/vanilla';
import { getBlockSuiteWorkspaceAtom } from './__internal__/workspace';
// global store
let rootStore = createStore();
export function getCurrentStore() {
return rootStore;
}
/**
* @internal do not use this function unless you know what you are doing
*/
export function _setCurrentStore(store: ReturnType<typeof createStore>) {
rootStore = store;
}
export const loadedPluginNameAtom = atom<string[]>([]);
export const currentWorkspaceIdAtom = atom<string | null>(null);
export const currentPageIdAtom = atom<string | null>(null);
export const currentWorkspaceAtom = atom<Promise<Workspace>>(async get => {
const workspaceId = get(currentWorkspaceIdAtom);
assertExists(workspaceId);
const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
return get(currentWorkspaceAtom);
});
import { atom } from 'jotai';
const contentLayoutBaseAtom = atom<ExpectedLayout>('editor');
@@ -0,0 +1,15 @@
import { createStore } from 'jotai';
// global store
let rootStore = createStore();
export function getCurrentStore() {
return rootStore;
}
/**
* @internal do not use this function unless you know what you are doing
*/
export function _setCurrentStore(store: ReturnType<typeof createStore>) {
rootStore = store;
}
@@ -1,5 +1,9 @@
import { setupGlobal } from '@affine/env/global';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { atomEffect } from 'jotai-effect';
setupGlobal();
export type DateFormats =
| 'MM/dd/YYYY'
@@ -63,15 +67,35 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
type SetStateAction<Value> = Value | ((prev: Value) => Value);
const appSettingEffect = atomEffect(get => {
const settings = get(appSettingBaseAtom);
// some values in settings should be synced into electron side
if (environment.isDesktop) {
console.log('set config', settings);
window.apis?.updater
.setConfig({
autoCheckUpdate: settings.autoCheckUpdate,
autoDownloadUpdate: settings.autoDownloadUpdate,
})
.catch(err => {
console.error(err);
});
}
});
export const appSettingAtom = atom<
AppSetting,
[SetStateAction<Partial<AppSetting>>],
void
>(
get => get(appSettingBaseAtom),
(get, set, apply) => {
const prev = get(appSettingBaseAtom);
const next = typeof apply === 'function' ? apply(prev) : apply;
set(appSettingBaseAtom, { ...prev, ...next });
get => {
get(appSettingEffect);
return get(appSettingBaseAtom);
},
(_get, set, apply) => {
set(appSettingBaseAtom, prev => {
const next = typeof apply === 'function' ? apply(prev) : apply;
return { ...prev, ...next };
});
}
);
@@ -0,0 +1,14 @@
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { atom } from 'jotai';
import { getBlockSuiteWorkspaceAtom } from '../__internal__/workspace';
export const currentWorkspaceIdAtom = atom<string | null>(null);
export const currentPageIdAtom = atom<string | null>(null);
export const currentWorkspaceAtom = atom<Promise<Workspace>>(async get => {
const workspaceId = get(currentWorkspaceIdAtom);
assertExists(workspaceId);
const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
return get(currentWorkspaceAtom);
});
@@ -1,3 +1,4 @@
import { DebugLogger } from '@affine/debug';
// @ts-expect-error upstream type is wrong
import { tinykeys } from 'tinykeys';
@@ -7,12 +8,14 @@ import {
createAffineCommand,
} from './command';
const commandLogger = new DebugLogger('command:registry');
export const AffineCommandRegistry = new (class {
readonly commands: Map<string, AffineCommand> = new Map();
register(options: AffineCommandOptions) {
if (this.commands.has(options.id)) {
console.warn(`Command ${options.id} already registered.`);
commandLogger.warn(`Command ${options.id} already registered.`);
return () => {};
}
const command = createAffineCommand(options);
@@ -38,17 +41,17 @@ export const AffineCommandRegistry = new (class {
});
}
console.debug(`Registered command ${command.id}`);
commandLogger.debug(`Registered command ${command.id}`);
return () => {
unsubKb?.();
this.commands.delete(command.id);
console.debug(`Unregistered command ${command.id}`);
commandLogger.debug(`Unregistered command ${command.id}`);
};
}
get(id: string): AffineCommand | undefined {
if (!this.commands.has(id)) {
console.warn(`Command ${id} not registered.`);
commandLogger.warn(`Command ${id} not registered.`);
return undefined;
}
return this.commands.get(id);
+1 -1
View File
@@ -201,7 +201,7 @@ export type UpdaterHandlers = {
downloadUpdate: () => Promise<void>;
getConfig: () => Promise<UpdaterConfig>;
setConfig: (newConfig: Partial<UpdaterConfig>) => Promise<void>;
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
checkForUpdates: () => Promise<{ version: string } | null>;
};
export type WorkspaceHandlers = {
+6
View File
@@ -11,6 +11,12 @@
{
"path": "../sdk"
},
{
"path": "../env"
},
{
"path": "../debug"
},
{
"path": "./tsconfig.node.json"
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
entry: {
blocksuite: resolve(root, 'src/blocksuite/index.ts'),
index: resolve(root, 'src/index.ts'),
atom: resolve(root, 'src/atom.ts'),
atom: resolve(root, 'src/atom/index.ts'),
command: resolve(root, 'src/command/index.ts'),
type: resolve(root, 'src/type.ts'),
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
+6 -6
View File
@@ -22,16 +22,16 @@
"dist"
],
"dependencies": {
"@blocksuite/block-std": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/blocks": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/editor": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/global": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/block-std": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/blocks": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/global": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/presets": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"jotai": "^2.5.1",
"zod": "^3.22.4"
},
"devDependencies": {
"vite": "^4.4.11",
"vite": "^5.0.6",
"vite-plugin-dts": "3.6.0"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { BaseSelection } from '@blocksuite/block-std';
import type { EditorContainer } from '@blocksuite/editor';
import type { EditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import type { Workspace } from '@blocksuite/store';
import type { Atom, getDefaultStore } from 'jotai/vanilla';
+4 -4
View File
@@ -32,15 +32,15 @@
}
},
"dependencies": {
"idb": "^7.1.1",
"idb": "^8.0.0",
"nanoid": "^5.0.3",
"y-provider": "workspace:*"
},
"devDependencies": {
"@blocksuite/blocks": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/blocks": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"fake-indexeddb": "^5.0.0",
"vite": "^4.4.11",
"vite": "^5.0.6",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"y-indexeddb": "^9.0.11",
@@ -65,7 +65,7 @@ beforeEach(() => {
id = nanoid();
workspace = new Workspace({
id,
isSSR: true,
schema,
});
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
@@ -383,7 +383,7 @@ describe('subDoc', () => {
{
const newWorkspace = new Workspace({
id,
isSSR: true,
schema,
});
const provider = createIndexedDBProvider(newWorkspace.doc, rootDBName);
@@ -423,7 +423,7 @@ describe('utils', () => {
expect(update).toBeInstanceOf(Uint8Array);
const newWorkspace = new Workspace({
id,
isSSR: true,
schema,
});
applyUpdate(newWorkspace.doc, update);
+2 -2
View File
@@ -24,8 +24,8 @@
"build": "vite build"
},
"devDependencies": {
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"vite": "^4.4.11",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"vite": "^5.0.6",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"yjs": "^13.6.10"
@@ -0,0 +1,54 @@
import { StorybookConfig } from '@storybook/react-vite';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { fileURLToPath } from 'url';
import { mergeConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { getRuntimeConfig } from '../../core/.webpack/runtime-config';
export default {
stories: ['../src/ui/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-mdx-gfm',
'storybook-dark-mode',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
features: {
storyStoreV7: true,
},
docs: {
autodocs: true,
},
async viteFinal(config, _options) {
return mergeConfig(config, {
plugins: [
vanillaExtractPlugin(),
tsconfigPaths({
root: fileURLToPath(new URL('../../../../', import.meta.url)),
ignoreConfigErrors: true,
}),
],
define: {
'process.on': '(() => void 0)',
'process.env': {},
'process.env.COVERAGE': JSON.stringify(!!process.env.COVERAGE),
'process.env.SHOULD_REPORT_TRACE': `${Boolean(
process.env.SHOULD_REPORT_TRACE === 'true'
)}`,
'process.env.TRACE_REPORT_ENDPOINT': `"${process.env.TRACE_REPORT_ENDPOINT}"`,
'process.env.CAPTCHA_SITE_KEY': `"${process.env.CAPTCHA_SITE_KEY}"`,
runtimeConfig: getRuntimeConfig({
distribution: 'browser',
mode: 'development',
channel: 'canary',
coverage: false,
}),
},
});
},
} satisfies StorybookConfig;
@@ -0,0 +1,27 @@
import { darkCssVariables, lightCssVariables } from '@toeverything/theme';
import { globalStyle } from '@vanilla-extract/css';
globalStyle('*', {
margin: 0,
padding: 0,
});
globalStyle('body', {
color: 'var(--affine-text-primary-color)',
fontFamily: 'var(--affine-font-family)',
fontSize: 'var(--affine-font-base)',
lineHeight: 'var(--affine-font-height)',
backgroundColor: 'var(--affine-background-primary-color)',
});
globalStyle('html', {
vars: lightCssVariables,
});
globalStyle('html[data-theme="dark"]', {
vars: darkCssVariables,
});
globalStyle('.docs-story', {
backgroundColor: 'var(--affine-background-primary-color)',
});
@@ -0,0 +1,3 @@
<script>
window.global = window;
</script>
@@ -0,0 +1,60 @@
import './preview.css';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ComponentType } from 'react';
import { useEffect } from 'react';
import { useDarkMode } from 'storybook-dark-mode';
import type { Preview } from '@storybook/react';
import React from 'react';
export const parameters: Preview = {
argTypes: {
param: {
table: { category: 'Group' },
},
},
globalTypes: {
theme: {
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: ['light', 'dark'],
dynamicTitle: true,
},
},
},
};
const ThemeChange = () => {
const isDark = useDarkMode();
const theme = useTheme();
if (theme.resolvedTheme === 'dark' && !isDark) {
theme.setTheme('light');
} else if (theme.resolvedTheme === 'light' && isDark) {
theme.setTheme('dark');
}
return null;
};
const Component = () => {
const isDark = useDarkMode();
const theme = useTheme();
useEffect(() => {
theme.setTheme(isDark ? 'dark' : 'light');
}, [isDark]);
return null;
};
export const decorators = [
(Story: ComponentType, context) => {
return (
<ThemeProvider themes={['dark', 'light']} enableSystem={true}>
<ThemeChange />
<Component />
<Story {...context} />
</ThemeProvider>
);
},
];
+28 -8
View File
@@ -5,13 +5,17 @@
"exports": {
".": "./src/index.ts",
"./theme/*": "./src/theme/*",
"./ui/*": "./src/ui/*/index.ts",
"./*": "./src/components/*/index.tsx"
},
"scripts": {
"dev": "storybook dev -p 6006"
},
"peerDependencies": {
"@blocksuite/blocks": "*",
"@blocksuite/editor": "*",
"@blocksuite/global": "*",
"@blocksuite/icons": "2.1.34",
"@blocksuite/presets": "*",
"@blocksuite/store": "*"
},
"dependencies": {
@@ -20,7 +24,7 @@
"@affine/i18n": "workspace:*",
"@affine/workspace": "workspace:*",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
@@ -29,11 +33,14 @@
"@popperjs/core": "^2.11.8",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toolbar": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@toeverything/hooks": "workspace:*",
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^0.7.24",
@@ -64,13 +71,24 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@blocksuite/blocks": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/editor": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/global": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/blocks": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/global": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/icons": "2.1.36",
"@blocksuite/lit": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/store": "0.0.0-20231124123613-7c06e95d-nightly",
"@blocksuite/lit": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/presets": "0.11.0-nightly-202312070955-2b5bb47",
"@blocksuite/store": "0.11.0-nightly-202312070955-2b5bb47",
"@storybook/addon-actions": "^7.5.3",
"@storybook/addon-essentials": "^7.5.3",
"@storybook/addon-interactions": "^7.5.3",
"@storybook/addon-links": "^7.5.3",
"@storybook/addon-mdx-gfm": "^7.5.3",
"@storybook/addon-storysource": "^7.5.3",
"@storybook/blocks": "^7.5.3",
"@storybook/builder-vite": "^7.5.3",
"@storybook/jest": "^0.2.3",
"@storybook/react": "^7.5.3",
"@storybook/react-vite": "^7.5.3",
"@storybook/test-runner": "^0.15.2",
"@storybook/testing-library": "^0.2.2",
"@testing-library/react": "^14.0.0",
"@types/bytes": "^3.1.3",
@@ -80,8 +98,10 @@
"@types/react-dom": "^18.2.13",
"@vanilla-extract/css": "^1.13.0",
"fake-indexeddb": "^5.0.0",
"storybook": "^7.5.3",
"storybook-dark-mode": "^3.0.1",
"typescript": "^5.3.2",
"vite": "^4.4.11",
"vite": "^5.0.6",
"vitest": "0.34.6",
"yjs": "^13.6.10"
},
@@ -40,6 +40,11 @@ export const tipsContainer = style({
position: 'sticky',
gap: '16px',
containerType: 'inline-size',
'@media': {
'screen and (max-width: 520px)': {
flexWrap: 'wrap',
},
},
});
export const tipsMessage = style({
@@ -54,4 +59,9 @@ export const tipsRightItem = style({
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
'@media': {
'screen and (max-width: 520px)': {
width: '100%',
},
},
});
@@ -1,5 +1,6 @@
import { Button, IconButton } from '@affine/component/ui/button';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useCallback } from 'react';
import * as styles from './index.css';
@@ -17,13 +18,10 @@ export const LocalDemoTips = ({
onLogin,
onEnableCloud,
}: LocalDemoTipsProps) => {
const content = isLoggedIn
? 'This is a local demo workspace, and the data is stored locally. We recommend enabling AFFiNE Cloud.'
: 'This is a local demo workspace, and the data is stored locally in the browser. We recommend Enabling AFFiNE Cloud or downloading the client for a better experience.';
const t = useAFFiNEI18N();
const buttonLabel = isLoggedIn
? 'Enable AFFiNE Cloud'
: 'Sign in with AFFiNE Cloud';
? t['Enable AFFiNE Cloud']()
: t['Sign in and Enable']();
const handleClick = useCallback(() => {
if (isLoggedIn) {
@@ -34,7 +32,9 @@ export const LocalDemoTips = ({
return (
<div className={styles.tipsContainer} data-testid="local-demo-tips">
<div className={styles.tipsMessage}>{content}</div>
<div className={styles.tipsMessage}>
{t['com.affine.banner.local-warning']()}
</div>
<div className={styles.tipsRightItem}>
<div>
@@ -0,0 +1,24 @@
import * as styles from './index.css';
import { useNavConfig } from './use-nav-config';
export const DesktopNavbar = () => {
const config = useNavConfig();
return (
<div className={styles.topNavLinks}>
{config.map(item => {
return (
<a
key={item.title}
href={item.path}
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
{item.title}
</a>
);
})}
</div>
);
};
@@ -0,0 +1,89 @@
import { style } from '@vanilla-extract/css';
export const root = style({
height: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column',
fontSize: 'var(--affine-font-base)',
position: 'relative',
background: 'var(--affine-background-primary-color)',
});
export const affineLogo = style({
color: 'inherit',
});
export const topNav = style({
top: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 120px',
selectors: {
'&.mobile': {
padding: '16px 20px',
},
},
});
export const topNavLinks = style({
display: 'flex',
columnGap: 4,
});
export const topNavLink = style({
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
textDecoration: 'none',
padding: '4px 18px',
});
export const iconButton = style({
fontSize: '24px',
pointerEvents: 'auto',
selectors: {
'&.plain': {
color: 'var(--affine-text-primary-color)',
},
},
});
export const menu = style({
width: '100vw',
height: '100vh',
padding: '0',
background: 'var(--affine-background-primary-color)',
borderRadius: '0',
border: 'none',
boxShadow: 'none',
});
export const menuItem = style({
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
textDecoration: 'none',
padding: '12px 20px',
maxWidth: '100%',
position: 'relative',
borderRadius: '0',
transition: 'background 0.3s ease',
selectors: {
'&:after': {
position: 'absolute',
content: '""',
bottom: 0,
display: 'block',
width: 'calc(100% - 40px)',
height: '0.5px',
background: 'var(--affine-black-10)',
},
'&:not(:last-of-type)': {
marginBottom: '0',
},
},
});
@@ -0,0 +1 @@
export * from './layout';
@@ -0,0 +1,49 @@
import { Button } from '@affine/component/ui/button';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Logo1Icon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useCallback } from 'react';
import { DesktopNavbar } from './desktop-navbar';
import * as styles from './index.css';
import { MobileNavbar } from './mobile-navbar';
export const AffineOtherPageLayout = ({
isSmallScreen,
children,
}: {
isSmallScreen: boolean;
children: React.ReactNode;
}) => {
const t = useAFFiNEI18N();
const openDownloadLink = useCallback(() => {
open(runtimeConfig.downloadUrl, '_blank');
}, []);
return (
<div className={styles.root}>
<div
className={clsx(styles.topNav, {
mobile: isSmallScreen,
})}
>
<a href="/" rel="noreferrer" className={styles.affineLogo}>
<Logo1Icon width={24} height={24} />
</a>
{isSmallScreen ? (
<MobileNavbar />
) : (
<>
<DesktopNavbar />
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</>
)}
</div>
{children}
</div>
);
};
@@ -0,0 +1,50 @@
import { IconButton } from '@affine/component/ui/button';
import { Menu, MenuItem } from '@affine/component/ui/menu';
import { CloseIcon, PropertyIcon } from '@blocksuite/icons';
import { useState } from 'react';
import * as styles from './index.css';
import { useNavConfig } from './use-nav-config';
export const MobileNavbar = () => {
const [openMenu, setOpenMenu] = useState(false);
const navConfig = useNavConfig();
const menuItems = (
<>
{navConfig.map(item => {
return (
<MenuItem
key={item.title}
onClick={() => {
open(item.path, '_blank');
}}
className={styles.menuItem}
>
{item.title}
</MenuItem>
);
})}
</>
);
return (
<div>
<Menu
items={menuItems}
contentOptions={{
className: styles.menu,
sideOffset: 20,
}}
rootOptions={{
open: openMenu,
onOpenChange: setOpenMenu,
}}
>
<IconButton type="plain" className={styles.iconButton}>
{openMenu ? <CloseIcon /> : <PropertyIcon />}
</IconButton>
</Menu>
</div>
);
};
@@ -0,0 +1,27 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMemo } from 'react';
export const useNavConfig = () => {
const t = useAFFiNEI18N();
return useMemo(
() => [
{
title: t['com.affine.other-page.nav.official-website'](),
path: 'https://affine.pro',
},
{
title: t['com.affine.other-page.nav.affine-community'](),
path: 'https://community.affine.pro/home',
},
{
title: t['com.affine.other-page.nav.blog'](),
path: 'https://affine.pro/blog',
},
{
title: t['com.affine.other-page.nav.contact-us'](),
path: 'https://affine.pro/about-us',
},
],
[t]
);
};
@@ -85,12 +85,12 @@ export const installLabel = style({
flex: 1,
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
justifyContent: 'space-between',
});
export const installLabelNormal = style([
installLabel,
{
justifyContent: 'space-between',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'none',
@@ -103,6 +103,7 @@ export const installLabelHover = style([
installLabel,
{
display: 'none',
justifyContent: 'flex-start',
selectors: {
[`${root}:hover &, ${root}[data-updating=true] &`]: {
display: 'flex',
@@ -1,20 +1,11 @@
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import {
changelogCheckedAtom,
currentChangelogUnreadAtom,
currentVersionAtom,
downloadProgressAtom,
updateAvailableAtom,
updateReadyAtom,
useAppUpdater,
} from '@toeverything/hooks/use-app-updater';
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import { startTransition, useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { Tooltip } from '../../../ui/tooltip';
import * as styles from './index.css';
export interface AddPageButtonPureProps {
@@ -26,36 +17,178 @@ export interface AddPageButtonPureProps {
version: string;
allowAutoUpdate: boolean;
} | null;
autoDownload: boolean;
downloadProgress: number | null;
appQuitting: boolean;
className?: string;
style?: React.CSSProperties;
}
interface ButtonContentProps {
updateReady: boolean;
updateAvailable: {
version: string;
allowAutoUpdate: boolean;
} | null;
autoDownload: boolean;
downloadProgress: number | null;
appQuitting: boolean;
currentChangelogUnread: boolean;
onDismissCurrentChangelog: () => void;
}
function DownloadUpdate({ updateAvailable }: ButtonContentProps) {
const t = useAFFiNEI18N();
return (
<div className={styles.installLabel}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.downloadUpdate']()}
</span>
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
</div>
);
}
function UpdateReady({ updateAvailable, appQuitting }: ButtonContentProps) {
const t = useAFFiNEI18N();
return (
<div className={styles.updateAvailableWrapper}>
<div className={styles.installLabelNormal}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
</div>
<div className={styles.installLabelHover}>
<ResetIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t[appQuitting ? 'Loading' : 'com.affine.appUpdater.installUpdate']()}
</span>
</div>
</div>
);
}
function DownloadingUpdate({
updateAvailable,
downloadProgress,
}: ButtonContentProps) {
const t = useAFFiNEI18N();
return (
<div className={clsx([styles.updateAvailableWrapper])}>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.downloading']()}
</span>
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
</div>
<div className={styles.progress}>
<div
className={styles.progressInner}
style={{ width: `${downloadProgress}%` }}
/>
</div>
</div>
);
}
function OpenDownloadPage({ updateAvailable }: ButtonContentProps) {
const t = useAFFiNEI18N();
return (
<>
<div className={styles.installLabelNormal}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>{updateAvailable?.version}</span>
</div>
<div className={styles.installLabelHover}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.openDownloadPage']()}
</span>
</div>
</>
);
}
function WhatsNew({ onDismissCurrentChangelog }: ButtonContentProps) {
const t = useAFFiNEI18N();
const onClickClose: React.MouseEventHandler = useCallback(
e => {
onDismissCurrentChangelog();
e.stopPropagation();
},
[onDismissCurrentChangelog]
);
return (
<>
<div className={clsx([styles.whatsNewLabel])}>
<NewIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.whatsNew']()}
</span>
</div>
<div className={styles.closeIcon} onClick={onClickClose}>
<CloseIcon />
</div>
</>
);
}
const getButtonContentRenderer = (props: ButtonContentProps) => {
if (props.updateReady) {
return UpdateReady;
} else if (props.updateAvailable?.allowAutoUpdate) {
if (props.autoDownload && props.updateAvailable.allowAutoUpdate) {
return DownloadingUpdate;
} else {
return DownloadUpdate;
}
} else if (props.updateAvailable && !props.updateAvailable?.allowAutoUpdate) {
return OpenDownloadPage;
} else if (props.currentChangelogUnread) {
return WhatsNew;
}
return null;
};
export function AppUpdaterButtonPure({
updateReady,
onClickUpdate,
onDismissCurrentChangelog,
currentChangelogUnread,
updateAvailable,
autoDownload,
downloadProgress,
appQuitting,
className,
style,
}: AddPageButtonPureProps) {
const t = useAFFiNEI18N();
const contentProps = useMemo(
() => ({
updateReady,
updateAvailable,
currentChangelogUnread,
autoDownload,
downloadProgress,
appQuitting,
onDismissCurrentChangelog,
}),
[
updateReady,
updateAvailable,
currentChangelogUnread,
autoDownload,
downloadProgress,
appQuitting,
onDismissCurrentChangelog,
]
);
if (!updateAvailable && !currentChangelogUnread) {
return null;
}
const updateAvailableNode = updateAvailable
? updateAvailable.allowAutoUpdate
? renderUpdateAvailableAllowAutoUpdate()
: renderUpdateAvailableNotAllowAutoUpdate()
: null;
const whatsNew =
!updateAvailable && currentChangelogUnread ? renderWhatsNew() : null;
const ContentComponent = getButtonContentRenderer(contentProps);
const wrapWithTooltip = (
node: React.ReactElement,
@@ -72,102 +205,38 @@ export function AppUpdaterButtonPure({
);
};
const disabled = useMemo(() => {
if (appQuitting) {
return true;
}
if (updateAvailable?.allowAutoUpdate) {
return !updateReady && autoDownload;
}
return false;
}, [
appQuitting,
autoDownload,
updateAvailable?.allowAutoUpdate,
updateReady,
]);
return wrapWithTooltip(
<button
style={style}
className={clsx([styles.root, className])}
data-has-update={!!updateAvailable}
data-updating={appQuitting}
data-disabled={
(updateAvailable?.allowAutoUpdate && !updateReady) || appQuitting
}
data-disabled={disabled}
onClick={onClickUpdate}
>
{updateAvailableNode}
{whatsNew}
{ContentComponent ? <ContentComponent {...contentProps} /> : null}
<div className={styles.particles} aria-hidden="true"></div>
<span className={styles.halo} aria-hidden="true"></span>
</button>,
updateAvailable?.version
);
function renderUpdateAvailableAllowAutoUpdate() {
return (
<div className={clsx([styles.updateAvailableWrapper])}>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{!updateReady
? t['com.affine.appUpdater.downloading']()
: t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
{updateReady ? (
<div className={clsx([styles.installLabelHover])}>
<ResetIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t[
appQuitting ? 'Loading' : 'com.affine.appUpdater.installUpdate'
]()}
</span>
</div>
) : (
<div className={styles.progress}>
<div
className={styles.progressInner}
style={{ width: `${downloadProgress}%` }}
/>
</div>
)}
</div>
);
}
function renderUpdateAvailableNotAllowAutoUpdate() {
return (
<>
<div className={clsx([styles.installLabelNormal])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.updateAvailable']()}
</span>
<span className={styles.versionLabel}>
{updateAvailable?.version}
</span>
</div>
<div className={clsx([styles.installLabelHover])}>
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.openDownloadPage']()}
</span>
</div>
</>
);
}
function renderWhatsNew() {
return (
<>
<div className={clsx([styles.whatsNewLabel])}>
<NewIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>
{t['com.affine.appUpdater.whatsNew']()}
</span>
</div>
<div
className={styles.closeIcon}
onClick={e => {
onDismissCurrentChangelog();
e.stopPropagation();
}}
>
<CloseIcon />
</div>
</>
);
}
}
// Although it is called an input, it is actually a button.
@@ -178,62 +247,64 @@ export function AppUpdaterButton({
className?: string;
style?: React.CSSProperties;
}) {
const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom);
const updateReady = useAtomValue(updateReadyAtom);
const updateAvailable = useAtomValue(updateAvailableAtom);
const downloadProgress = useAtomValue(downloadProgressAtom);
const currentVersion = useAtomValue(currentVersionAtom);
const { quitAndInstall, appQuitting } = useAppUpdater();
const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom);
const dismissCurrentChangelog = useCallback(() => {
if (!currentVersion) {
return;
}
startTransition(() =>
setChangelogCheckAtom(mapping => {
return {
...mapping,
[currentVersion]: true,
};
})
);
}, [currentVersion, setChangelogCheckAtom]);
const {
quitAndInstall,
appQuitting,
autoDownload,
downloadUpdate,
readChangelog,
changelogUnread,
updateReady,
updateAvailable,
downloadProgress,
currentVersion,
} = useAppUpdater();
const handleClickUpdate = useCallback(() => {
if (updateReady) {
quitAndInstall();
} else if (updateAvailable) {
if (updateAvailable.allowAutoUpdate) {
// wait for download to finish
if (autoDownload) {
// wait for download to finish
} else {
downloadUpdate();
}
} else {
window.open(
`https://github.com/toeverything/AFFiNE/releases/tag/v${currentVersion}`,
'_blank'
);
}
} else if (currentChangelogUnread) {
} else if (changelogUnread) {
window.open(runtimeConfig.changelogUrl, '_blank');
dismissCurrentChangelog();
readChangelog();
} else {
throw new Unreachable();
}
}, [
updateReady,
quitAndInstall,
updateAvailable,
currentChangelogUnread,
dismissCurrentChangelog,
changelogUnread,
quitAndInstall,
autoDownload,
downloadUpdate,
currentVersion,
readChangelog,
]);
if (!updateAvailable && !changelogUnread) {
return null;
}
return (
<AppUpdaterButtonPure
appQuitting={appQuitting}
autoDownload={autoDownload}
updateReady={!!updateReady}
onClickUpdate={handleClickUpdate}
onDismissCurrentChangelog={dismissCurrentChangelog}
currentChangelogUnread={currentChangelogUnread}
onDismissCurrentChangelog={readChangelog}
currentChangelogUnread={changelogUnread}
updateAvailable={updateAvailable}
downloadProgress={downloadProgress}
className={className}
@@ -1,10 +1,10 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { IconButton } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import type { History } from '..';
import {
navHeaderButton,
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useAtom } from 'jotai';
import { IconButton } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import { appSidebarOpenAtom } from '../index.jotai';
import * as styles from './sidebar-switch.css';
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import type { FC, HTMLAttributes } from 'react';
import { Input, type InputProps } from '../../ui/input';
import { authInputWrapper, formHint } from './share.css';
import * as styles from './share.css';
export type AuthInputProps = InputProps & {
label?: string;
error?: boolean;
@@ -22,13 +22,14 @@ export const AuthInput: FC<AuthInputProps> = ({
}) => {
return (
<div
className={clsx(authInputWrapper, className, {
className={clsx(styles.authInputWrapper, className, {
'without-hint': withoutHint,
})}
{...otherWrapperProps}
>
{label ? <label>{label}</label> : null}
<Input
className={styles.input}
size="extraLarge"
status={error ? 'error' : 'default'}
onKeyDown={e => {
@@ -40,7 +41,7 @@ export const AuthInput: FC<AuthInputProps> = ({
/>
{error && errorHint && !withoutHint ? (
<div
className={clsx(formHint, {
className={clsx(styles.formHint, {
error: error,
})}
>
@@ -1,32 +1,41 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import {
type FC,
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from 'react';
import { Empty } from '../../ui/empty';
import { Wrapper } from '../../ui/layout';
import { Logo } from './logo';
import { AffineOtherPageLayout } from '../affine-other-page-layout';
import { authPageContainer } from './share.css';
export const AuthPageContainer: FC<
PropsWithChildren<{ title?: ReactNode; subtitle?: ReactNode }>
> = ({ children, title, subtitle }) => {
const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => {
const checkScreenSize = () => {
setIsSmallScreen(window.innerWidth <= 1024);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
return (
<div className={authPageContainer}>
<Wrapper
style={{
position: 'absolute',
top: 25,
left: 20,
}}
>
<Logo />
</Wrapper>
<div className="wrapper">
<div className="content">
<p className="title">{title}</p>
<p className="subtitle">{subtitle}</p>
{children}
<AffineOtherPageLayout isSmallScreen={isSmallScreen}>
<div className={authPageContainer}>
<div className="wrapper">
<div className="content">
<p className="title">{title}</p>
<p className="subtitle">{subtitle}</p>
{children}
</div>
{isSmallScreen ? null : <Empty />}
</div>
<Empty />
</div>
</div>
</AffineOtherPageLayout>
);
};
@@ -1,8 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowLeftSmallIcon } from '@blocksuite/icons';
import { Button, type ButtonProps } from '@toeverything/components/button';
import { type FC } from 'react';
import { Button, type ButtonProps } from '../../ui/button';
export const BackButton: FC<ButtonProps> = props => {
const t = useAFFiNEI18N();
return (
@@ -1,7 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useCallback, useState } from 'react';
import { Button } from '../../ui/button';
import { AuthInput } from './auth-input';
import { AuthPageContainer } from './auth-page-container';
import { emailRegex } from './utils';
@@ -44,7 +44,6 @@ export const ChangeEmailPage = ({
>
<>
<AuthInput
width={320}
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
value={email}
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { Button } from '../../ui/button';
import { pushNotificationAtom } from '../notification-center';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
@@ -1,7 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { Button } from '../../ui/button';
import { AuthPageContainer } from './auth-page-container';
export const ConfirmChangeEmail: FC<{
@@ -1,6 +1,7 @@
import { Modal } from '@toeverything/components/modal';
import type { FC, PropsWithChildren } from 'react';
import { Modal } from '../../ui/modal';
export type AuthModalProps = {
open: boolean;
setOpen: (value: boolean) => void;
@@ -4,6 +4,7 @@ import { type FC, useEffect } from 'react';
import { useCallback, useState } from 'react';
import { Input, type InputProps } from '../../../ui/input';
import * as styles from '../share.css';
import { ErrorIcon } from './error';
import { SuccessIcon } from './success';
import { Tag } from './tag';
@@ -74,6 +75,7 @@ export const PasswordInput: FC<
return (
<>
<Input
className={styles.input}
type="password"
size="extraLarge"
style={{ marginBottom: 20 }}
@@ -83,6 +85,7 @@ export const PasswordInput: FC<
{...inputProps}
/>
<Input
className={styles.input}
type="password"
size="extraLarge"
placeholder={t['com.affine.auth.set.password.placeholder.confirm']()}
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { Button } from '../../ui/button';
import { pushNotificationAtom } from '../notification-center';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
@@ -1,7 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { type FC, useCallback, useRef, useState } from 'react';
import { Button } from '../../ui/button';
import { Wrapper } from '../../ui/layout';
import { PasswordInput } from './password-input';
@@ -19,7 +19,6 @@ export const SetPassword: FC<{
<>
<Wrapper marginTop={30} marginBottom={42}>
<PasswordInput
width={320}
onPass={useCallback(password => {
setPasswordPass(true);
passwordRef.current = password;
@@ -151,14 +151,25 @@ export const authPageContainer = style({
justifyContent: 'center',
alignItems: 'center',
fontSize: 'var(--affine-font-base)',
'@media': {
'screen and (max-width: 1024px)': {
flexDirection: 'column',
padding: '100px 20px',
justifyContent: 'flex-start',
},
},
});
globalStyle(`${authPageContainer} .wrapper`, {
display: 'flex',
alignItems: 'center',
'@media': {
'screen and (max-width: 1024px)': {
flexDirection: 'column',
},
},
});
globalStyle(`${authPageContainer} .content`, {
maxWidth: '700px',
minWidth: '550px',
});
globalStyle(`${authPageContainer} .title`, {
@@ -178,3 +189,12 @@ export const signInPageContainer = style({
width: '400px',
margin: '205px auto 0',
});
export const input = style({
width: '330px',
'@media': {
'screen and (max-width: 520px)': {
width: '100%',
},
},
});
@@ -1,7 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { FC } from 'react';
import { Button } from '../../ui/button';
import { AuthPageContainer } from './auth-page-container';
export const SignInSuccessPage: FC<{
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { Button } from '../../ui/button';
import { pushNotificationAtom } from '../notification-center';
import { AuthPageContainer } from './auth-page-container';
import { SetPassword } from './set-password';
@@ -1,20 +0,0 @@
import { rootBlockHubAtom } from '@affine/workspace/atom';
import { useAtomValue } from 'jotai';
import { useEffect, useRef } from 'react';
export const RootBlockHub = () => {
const ref = useRef<HTMLDivElement>(null);
const blockHub = useAtomValue(rootBlockHubAtom);
useEffect(() => {
if (ref.current) {
const div = ref.current;
if (blockHub) {
if (div.hasChildNodes()) {
(div.firstChild as ChildNode).remove();
}
div.append(blockHub);
}
}
}, [blockHub]);
return <div ref={ref} data-testid="block-hub" />;
};
@@ -1,5 +1,5 @@
import { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils';
import { EditorContainer } from '@blocksuite/presets';
import type { Page } from '@blocksuite/store';
import clsx from 'clsx';
import { use } from 'foxact/use';
@@ -2,16 +2,16 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Divider } from '@toeverything/components/divider';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import { useAtomValue } from 'jotai/react';
import { useCallback } from 'react';
import { Avatar } from '../../../ui/avatar';
import { Divider } from '../../../ui/divider';
import { Skeleton } from '../../../ui/skeleton';
import { Tooltip } from '../../../ui/tooltip';
import {
StyledCard,
StyledIconContainer,
@@ -1,6 +1,5 @@
import { IconButton } from '@toeverything/components/button';
import { displayFlex, styled, textEllipsis } from '../../../styles';
import { IconButton } from '../../../ui/button';
export const StyledWorkspaceInfo = styled('div')(() => {
return {
@@ -1,8 +1,6 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
ConfirmModal,
type ConfirmModalProps,
} from '@toeverything/components/modal';
import { ConfirmModal, type ConfirmModalProps } from '../../ui/modal';
export const PublicLinkDisableModal = (props: ConfirmModalProps) => {
const t = useAFFiNEI18N();
@@ -6,9 +6,9 @@ import {
NewIcon,
NotionIcon,
} from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { IconButton } from '../../ui/button';
import { Tooltip } from '../../ui/tooltip';
import { BlockCard } from '../card/block-card';
import {
importPageBodyStyle,
@@ -1,9 +1,9 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { type GetInviteInfoQuery } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Avatar } from '@toeverything/components/avatar';
import { Button } from '@toeverything/components/button';
import { Avatar } from '../../ui/avatar';
import { Button } from '../../ui/button';
import { FlexWrapper } from '../../ui/layout';
import * as styles from './styles.css';
export const AcceptInvitePage = ({
@@ -1,8 +1,8 @@
import { Permission } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ConfirmModal } from '@toeverything/components/modal';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmModal } from '../../ui/modal';
import { AuthInput } from '..//auth-components';
import { emailRegex } from '..//auth-components/utils';
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SignOutIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button, IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { Avatar } from '../../ui/avatar';
import { Button, IconButton } from '../../ui/button';
import { Tooltip } from '../../ui/tooltip';
import { NotFoundPattern } from './not-found-pattern';
import {
largeButtonEffect,
@@ -8,6 +8,7 @@ export const notFoundPageContainer = style({
alignItems: 'center',
justifyContent: 'center',
width: '100vw',
padding: '0 20px',
});
export const wrapper = style({
@@ -4,7 +4,6 @@
import { CloseIcon, InformationFillDuotoneIcon } from '@blocksuite/icons';
import * as Toast from '@radix-ui/react-toast';
import { IconButton } from '@toeverything/components/button';
import clsx from 'clsx';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { ReactNode } from 'react';
@@ -17,6 +16,7 @@ import {
useState,
} from 'react';
import { IconButton } from '../../ui/button';
import { SuccessIcon } from './icons';
import * as styles from './index.css';
import type { Notification } from './index.jotai';
@@ -1,13 +1,10 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
import {
IconButton,
type IconButtonProps,
} from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import Lottie from 'lottie-react';
import { forwardRef, useCallback, useState } from 'react';
import { IconButton, type IconButtonProps } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import favoritedAnimation from './favorited-animation/data.json';
export const FavoriteTag = forwardRef<
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
import { Menu } from '@toeverything/components/menu';
import { type PropsWithChildren, useCallback, useState } from 'react';
import { DropdownButton } from '../../../ui/button';
import { Menu } from '../../../ui/menu';
import { BlockCard } from '../../card/block-card';
import { menuContent } from './new-page-button.css';
@@ -1,9 +1,9 @@
import type { Filter, Literal } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { Menu, MenuItem } from '@toeverything/components/menu';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { Menu, MenuItem } from '../../../ui/menu';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { literalMatcher } from './literal-matcher';
@@ -2,10 +2,10 @@ import type { Filter } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, PlusIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { IconButton } from '@toeverything/components/button';
import { Menu } from '@toeverything/components/menu';
import { Button } from '../../../ui/button';
import { IconButton } from '../../../ui/button';
import { Menu } from '../../../ui/menu';
import { Condition } from './condition';
import * as styles from './index.css';
import { CreateFilterMenu } from './vars';
@@ -19,6 +19,8 @@ const useFilterTag = ({ name }: FilterTagProps) => {
return t['com.affine.filter.after']();
case 'before':
return t['com.affine.filter.before']();
case 'last':
return t['com.affine.filter.last']();
case 'is':
return t['com.affine.filter.is']();
case 'is not empty':
@@ -1,11 +1,13 @@
import type { LiteralValue, Tag } from '@affine/env/filter';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { type ReactNode } from 'react';
import Input from '../../../ui/input';
import { Menu, MenuItem } from '../../../ui/menu';
import { AFFiNEDatePicker } from '../../date-picker';
import { FilterTag } from './filter-tag-translation';
import { inputStyle } from './index.css';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TType } from './logical/typesystem';
import { tArray, typesystem } from './logical/typesystem';
@@ -21,6 +23,37 @@ export const literalMatcher = new Matcher<{
return typesystem.isSubtype(type, target);
});
literalMatcher.register(tDateRange.create(), {
render: ({ value, onChange }) => (
<Menu
items={
<div>
<Input
type="number"
// Handle the input change and update the value accordingly
onChange={i => (i ? onChange(parseInt(i)) : onChange(0))}
/>
{[1, 2, 3, 7, 14, 30].map(i => (
<MenuItem
key={i}
onClick={() => {
// Handle the menu item click and update the value accordingly
onChange(i);
}}
>
{i} {i > 1 ? 'days' : 'day'}
</MenuItem>
))}
</div>
}
>
<div>
<span>{value.toString()}</span> {(value as number) > 1 ? 'days' : 'day'}
</div>
</Menu>
),
});
literalMatcher.register(tBoolean.create(), {
render: ({ value, onChange }) => (
<div
@@ -19,3 +19,7 @@ export const tTag = typesystem.defineData<{ tags: Tag[] }>({
name: 'Tag',
supers: [],
});
export const tDateRange = typesystem.defineData(
DataHelper.create<{ value: number }>('DateRange')
);
@@ -1,7 +1,7 @@
import { Menu, MenuItem } from '@toeverything/components/menu';
import type { MouseEvent } from 'react';
import { useMemo } from 'react';
import { Menu, MenuItem } from '../../../ui/menu';
import * as styles from './multi-select.css';
export const MultiSelect = ({
@@ -5,17 +5,13 @@ import type {
VariableMap,
} from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
MenuIcon,
MenuItem,
MenuSeparator,
} from '@toeverything/components/menu';
import dayjs from 'dayjs';
import type { ReactNode } from 'react';
import { MenuIcon, MenuItem, MenuSeparator } from '../../../ui/menu';
import { FilterTag } from './filter-tag-translation';
import * as styles from './index.css';
import { tBoolean, tDate, tTag } from './logical/custom-type';
import { tBoolean, tDate, tDateRange, tTag } from './logical/custom-type';
import { Matcher } from './logical/matcher';
import type { TFunction } from './logical/typesystem';
import {
@@ -165,6 +161,24 @@ filterMatcher.register(
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDateRange.create()],
rt: tBoolean.create(),
}),
{
name: 'last',
defaultArgs: () => [30], // Default to the last 30 days
impl: (date, n) => {
if (typeof date !== 'number' || typeof n !== 'number') {
throw new Error('Argument type error: date and n must be numbers');
}
const startDate = dayjs().subtract(n, 'day').startOf('day').valueOf();
return date > startDate;
},
}
);
filterMatcher.register(
tFunction({
args: [tDate.create(), tDate.create()],
@@ -7,13 +7,13 @@ import {
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
import { ConfirmModal } from '@toeverything/components/modal';
import { Tooltip } from '@toeverything/components/tooltip';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { IconButton } from '../../ui/button';
import { Menu, MenuIcon, MenuItem } from '../../ui/menu';
import { ConfirmModal } from '../../ui/modal';
import { Tooltip } from '../../ui/tooltip';
import { FavoriteTag } from './components/favorite-tag';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
import * as styles from './page-list.css';
@@ -66,6 +66,7 @@ export const OperationCell = ({
</MenuItem>
{!environment.isDesktop && (
<Link
className={styles.clearLinkStyle}
onClick={stopPropagationWithoutPrevent}
to={link}
target={'_blank'}
@@ -1,11 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ShareIcon } from '@blocksuite/icons';
import {
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import { MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
import { PublicLinkDisableModal } from '../../disable-public-link';
export const DisablePublicSharing = (props: MenuItemProps) => {
@@ -6,9 +6,9 @@ import {
ExportToPdfIcon,
ExportToPngIcon,
} from '@blocksuite/icons';
import { MenuIcon, MenuItem, MenuSub } from '@toeverything/components/menu';
import { type ReactNode, useMemo } from 'react';
import { MenuIcon, MenuItem, MenuSub } from '../../../ui/menu';
import { transitionStyle } from './index.css';
interface ExportMenuItemProps<T> {
@@ -1,14 +1,8 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon } from '@blocksuite/icons';
import {
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import {
ConfirmModal,
type ConfirmModalProps,
} from '@toeverything/components/modal';
import { MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
import { ConfirmModal, type ConfirmModalProps } from '../../../ui/modal';
export const MoveToTrash = (props: MenuItemProps) => {
const t = useAFFiNEI18N();
@@ -120,3 +120,14 @@ export const favoriteCell = style({
},
},
});
export const clearLinkStyle = style({
color: 'inherit',
textDecoration: 'none',
':visited': {
color: 'inherit',
},
':active': {
color: 'inherit',
},
});
@@ -1,10 +1,10 @@
import type { Tag } from '@affine/env/filter';
import { MoreHorizontalIcon } from '@blocksuite/icons';
import { Menu } from '@toeverything/components/menu';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useMemo } from 'react';
import { Menu } from '../../ui/menu';
import * as styles from './page-tags.css';
import { stopPropagation } from './utils';
@@ -2,11 +2,11 @@ import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter';
import type { GetPageInfoById } from '@affine/env/page-info';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ViewLayersIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { useState } from 'react';
import { Button } from '../../../ui/button';
import { Tooltip } from '../../../ui/tooltip';
import {
type CollectionsCRUDAtom,
useCollectionManager,
@@ -6,11 +6,11 @@ import type {
import type { PropertiesMeta } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Menu } from '@toeverything/components/menu';
import { useCallback, useState } from 'react';
import { Button } from '../../../ui/button';
import { FlexWrapper } from '../../../ui/layout';
import { Menu } from '../../../ui/menu';
import { CreateFilterMenu } from '../filter/vars';
import type { useCollectionManager } from '../use-collection-manager';
import * as styles from './collection-list.css';
@@ -1,12 +1,6 @@
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons';
import {
Menu,
MenuIcon,
MenuItem,
type MenuItemProps,
} from '@toeverything/components/menu';
import {
type PropsWithChildren,
type ReactElement,
@@ -14,6 +8,7 @@ import {
useMemo,
} from 'react';
import { Menu, MenuIcon, MenuItem, type MenuItemProps } from '../../../ui/menu';
import type { useCollectionManager } from '../use-collection-manager';
import type { AllPageListConfig } from '.';
import * as styles from './collection-operations.css';
@@ -1,9 +1,9 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Modal } from '@toeverything/components/modal';
import { useCallback, useMemo, useState } from 'react';
import { Button } from '../../../ui/button';
import Input from '../../../ui/input';
import { Modal } from '../../../ui/modal';
import * as styles from './create-collection.css';
export interface CreateCollectionModalProps {
@@ -2,11 +2,11 @@ import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta, Workspace } from '@blocksuite/store';
import type { DialogContentProps } from '@radix-ui/react-dialog';
import { Button } from '@toeverything/components/button';
import { Modal } from '@toeverything/components/modal';
import { type ReactNode, useCallback, useMemo, useState } from 'react';
import { RadioButton, RadioButtonGroup } from '../../../../ui/button';
import { RadioButton, RadioButtonGroup } from '../../../../index';
import { Button } from '../../../../ui/button';
import { Modal } from '../../../../ui/modal';
import * as styles from './edit-collection.css';
import { PagesMode } from './pages-mode';
import { RulesMode } from './rules-mode';
@@ -1,6 +1,6 @@
import { Modal } from '@toeverything/components/modal';
import { useCallback, useState } from 'react';
import { Modal } from '../../../../ui/modal';
import type { AllPageListConfig } from './edit-collection';
import { SelectPage } from './select-page';
export const useSelectPage = ({
@@ -2,10 +2,10 @@ import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx';
import { type ReactNode, useCallback } from 'react';
import { Menu } from '../../../../ui/menu';
import { FilterList } from '../../filter/filter-list';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
@@ -1,11 +1,11 @@
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Menu } from '@toeverything/components/menu';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { Button } from '../../../../ui/button';
import { Menu } from '../../../../ui/menu';
import { FilterList } from '../../filter';
import { VariableSelect } from '../../filter/vars';
import { VirtualizedPageList } from '../../virtualized-page-list';
@@ -1,10 +1,10 @@
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SaveIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { Button } from '../../../ui/button';
import { createEmptyCollection } from '../use-collection-manager';
import { useEditCollectionName } from './use-edit-collection';
@@ -1,11 +1,11 @@
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import bytes from 'bytes';
import clsx from 'clsx';
import { useMemo } from 'react';
import { Button } from '../../ui/button';
import { Tooltip } from '../../ui/tooltip';
import * as styles from './share.css';
export interface StorageProgressProgress {

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