feat(mobile): create notebooks in Android and iOS apps
This commit is contained in:
1 parent
66b40b0ee0
commit
8472f19e46
13 files changed
+343
-16
No files matched your search
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A1D1EC7F16E745A68CE9B6D1 /* AiDirectStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1EC7F16E745A68CE9B6D2 /* AiDirectStream.swift */; };
|
||||
039594C5BEE4844FBB131C7F /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F510BF90B418266F363EBD /* APIClient.swift */; };
|
||||
0614453E8E9EC13B212B5A2F /* NotebookPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3328D871910187ED827B6CE /* NotebookPickerSheet.swift */; };
|
||||
06D2661CAB0ADED27F322649 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D87A8CED8498AE654EF8CD1F /* Localizable.xcstrings */; };
|
||||
@@ -119,6 +120,7 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A1D1EC7F16E745A68CE9B6D2 /* AiDirectStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiDirectStream.swift; sourceTree = "<group>"; };
|
||||
033565CE2E96EC2D23105188 /* ListActionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListActionsSheet.swift; sourceTree = "<group>"; };
|
||||
0531FC6AA81DAF0A55B42362 /* AppDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
|
||||
12236E3A2F318CDEB17B80CB /* SyncProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -450,6 +452,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6F510BF90B418266F363EBD /* APIClient.swift */,
|
||||
A1D1EC7F16E745A68CE9B6D2 /* AiDirectStream.swift */,
|
||||
);
|
||||
path = Network;
|
||||
sourceTree = "<group>";
|
||||
@@ -653,6 +656,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
039594C5BEE4844FBB131C7F /* APIClient.swift in Sources */,
|
||||
A1D1EC7F16E745A68CE9B6D1 /* AiDirectStream.swift in Sources */,
|
||||
564B7A4D6CD3C8140286F881 /* AccountPasswordPanel.swift in Sources */,
|
||||
F6AACA31885AB69C1CCCCB46 /* AiAssistantSheet.swift in Sources */,
|
||||
12F0F0D1E937AA88C33222FF /* ApiTokensView.swift in Sources */,
|
||||
|
||||
@@ -70,6 +70,12 @@ final class LocalMirrorRepository: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func saveNotebook(scope: String, notebook: Notebook) throws {
|
||||
try dbQueue.write { db in
|
||||
try upsertNotebook(db, scope: scope, notebook: notebook)
|
||||
}
|
||||
}
|
||||
|
||||
func listTags(scope: String) throws -> [TagSummary] {
|
||||
try dbQueue.read { db in
|
||||
let rows = try Row.fetchAll(
|
||||
|
||||
@@ -99,6 +99,21 @@ actor APIClient {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Notebooks
|
||||
|
||||
func createNotebook(name: String, parentId: String?) async throws -> Notebook {
|
||||
struct Body: Encodable {
|
||||
var name: String
|
||||
var parentId: String?
|
||||
}
|
||||
let response: NotebookResponse = try await request(
|
||||
path: "/api/v1/notebooks",
|
||||
method: "POST",
|
||||
body: Body(name: name, parentId: parentId)
|
||||
)
|
||||
return response.notebook
|
||||
}
|
||||
|
||||
// MARK: - Memos
|
||||
|
||||
func createMemo(
|
||||
|
||||
@@ -8,6 +8,11 @@ struct NotebookPickerSheet: View {
|
||||
|
||||
@State private var query = ""
|
||||
@State private var collapsedIds: Set<String> = []
|
||||
@State private var isCreating = false
|
||||
@State private var isSaving = false
|
||||
@State private var newName = ""
|
||||
@State private var newParentId: String?
|
||||
@State private var createError: String?
|
||||
|
||||
private var parentIds: Set<String> {
|
||||
NotebookHierarchy.parentIdsWithChildren(from: store.notebooks)
|
||||
@@ -55,6 +60,17 @@ struct NotebookPickerSheet: View {
|
||||
.foregroundStyle(AppTheme.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
isCreating.toggle()
|
||||
createError = nil
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(env.preferences.t("新建笔记本", en: "New notebook", ja: "新しいノートブック"))
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
@@ -71,6 +87,51 @@ struct NotebookPickerSheet: View {
|
||||
Rectangle().fill(AppTheme.border).frame(height: 1)
|
||||
}
|
||||
|
||||
if isCreating {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField(env.preferences.t("笔记本名称", en: "Notebook name", ja: "ノートブック名"), text: $newName)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppTheme.title)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 42)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppTheme.border))
|
||||
HStack(spacing: 8) {
|
||||
parentChoice(env.preferences.t("顶层笔记本", en: "Top level", ja: "最上位"), id: nil)
|
||||
if let selected = store.selectedNotebookId {
|
||||
parentChoice(env.preferences.t("位于「\(activeName)」下", en: "Under \(activeName)", ja: "「\(activeName)」の下"), id: selected)
|
||||
}
|
||||
}
|
||||
if let createError {
|
||||
Text(createError)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
Button {
|
||||
Task { await createNotebook() }
|
||||
} label: {
|
||||
Group {
|
||||
if isSaving {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text(env.preferences.t("创建笔记本", en: "Create notebook", ja: "ノートブックを作成"))
|
||||
}
|
||||
}
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 40)
|
||||
.background(AppTheme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSaving || newName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Search
|
||||
@@ -194,9 +255,11 @@ struct NotebookPickerSheet: View {
|
||||
}
|
||||
}
|
||||
.background(AppTheme.card)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents(isCreating ? [.large] : [.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.onAppear {
|
||||
isCreating = store.startCreatingNotebook
|
||||
store.startCreatingNotebook = false
|
||||
// Match Android: collapse branches not on the path to the active notebook.
|
||||
let parents = parentIds
|
||||
if let selected = store.selectedNotebookId {
|
||||
@@ -209,6 +272,48 @@ struct NotebookPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func parentChoice(_ title: String, id: String?) -> some View {
|
||||
Button {
|
||||
newParentId = id
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.title)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.stroke(newParentId == id ? AppTheme.accent : AppTheme.border)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(newParentId == id ? .isSelected : [])
|
||||
}
|
||||
|
||||
private func createNotebook() async {
|
||||
let name = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !isSaving, !name.isEmpty, name.count <= 80 else { return }
|
||||
isSaving = true
|
||||
createError = nil
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
let notebook = try await env.session.client.createNotebook(name: name, parentId: newParentId)
|
||||
if let scope = env.session.dataScope {
|
||||
try? env.mirror.saveNotebook(scope: scope, notebook: notebook)
|
||||
}
|
||||
store.selectedTag = nil
|
||||
store.selectedNotebookId = notebook.id
|
||||
store.reload(env: env)
|
||||
if !store.notebooks.contains(where: { $0.id == notebook.id }) {
|
||||
store.notebooks.append(notebook)
|
||||
}
|
||||
dismiss()
|
||||
Task { await env.runSyncCycle() }
|
||||
} catch {
|
||||
createError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ id: String?) {
|
||||
store.selectedTag = nil
|
||||
store.selectedNotebookId = id
|
||||
|
||||
@@ -7,6 +7,7 @@ struct NotesListView: View {
|
||||
@Binding var path: NavigationPath
|
||||
var onCreateNote: (() -> Void)? = nil
|
||||
var onCreateFromTemplate: (() -> Void)? = nil
|
||||
var onCreateNotebook: (() -> Void)? = nil
|
||||
|
||||
/// Whole-list settle + Pow jump once when data first becomes available this session.
|
||||
@State private var listEntranceSettled = false
|
||||
@@ -61,10 +62,12 @@ struct NotesListView: View {
|
||||
emptyCard(
|
||||
title: env.preferences.t("暂无笔记本", en: "No notebooks"),
|
||||
description: env.preferences.t(
|
||||
"同步完成后,笔记本会出现在这里。可在桌面/Web 端创建笔记本。",
|
||||
en: "Notebooks appear after sync. Create them on desktop/web."
|
||||
"先创建一个笔记本,再开始记录。",
|
||||
en: "Create a notebook to start taking notes.",
|
||||
ja: "ノートブックを作成して記録を始めましょう。"
|
||||
),
|
||||
showCreate: false
|
||||
showCreate: false,
|
||||
showCreateNotebook: true
|
||||
)
|
||||
.transition(Motion.softFade)
|
||||
} else if store.memos.isEmpty && !store.isLoadingList {
|
||||
@@ -440,7 +443,7 @@ struct NotesListView: View {
|
||||
}
|
||||
|
||||
/// Android `memoListEmptyCard` + `emptyTitle` / `mutedText` + dual create actions.
|
||||
private func emptyCard(title: String, description: String, showCreate: Bool) -> some View {
|
||||
private func emptyCard(title: String, description: String, showCreate: Bool, showCreateNotebook: Bool = false) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: AppTheme.heavy))
|
||||
@@ -449,6 +452,19 @@ struct NotesListView: View {
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
if showCreateNotebook, let onCreateNotebook {
|
||||
Button(action: onCreateNotebook) {
|
||||
Label(env.preferences.t("新建笔记本", en: "New notebook", ja: "新しいノートブック"), systemImage: "plus")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 38)
|
||||
.background(AppTheme.accent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("emptyCreateNotebook")
|
||||
}
|
||||
if showCreate, !store.notebooks.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
if let onCreateNote {
|
||||
|
||||
@@ -19,6 +19,7 @@ final class WorkspaceStore {
|
||||
var selectionMode = false
|
||||
var selectedMemoIds: Set<String> = []
|
||||
var showNotebookPicker = false
|
||||
var startCreatingNotebook = false
|
||||
var showActions = false
|
||||
var showTagFilterPicker = false
|
||||
|
||||
|
||||
@@ -57,7 +57,11 @@ struct WorkspaceView: View {
|
||||
store: store,
|
||||
path: $path,
|
||||
onCreateNote: { openCreateNote() },
|
||||
onCreateFromTemplate: { openCreateFromTemplate() }
|
||||
onCreateFromTemplate: { openCreateFromTemplate() },
|
||||
onCreateNotebook: {
|
||||
store.startCreatingNotebook = true
|
||||
store.showNotebookPicker = true
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,11 @@ export const listLocalNotebooks = async (scope: string): Promise<{ notebooks: No
|
||||
};
|
||||
};
|
||||
|
||||
export const upsertLocalNotebook = async (scope: string, notebook: Notebook) => {
|
||||
const db = await getDatabase();
|
||||
await upsertNotebook(db, scope, notebook);
|
||||
};
|
||||
|
||||
export const listLocalTags = async (scope: string): Promise<{ tags: TagSummary[] }> => {
|
||||
const db = await getDatabase();
|
||||
const rows = await db.getAllAsync<StoredMemoRow>(
|
||||
|
||||
@@ -19,6 +19,17 @@ type MobileLocaleContextValue = {
|
||||
type TranslationPair = { source: string; target: string; pattern?: RegExp; placeholders?: string[] };
|
||||
|
||||
const mobileOnlyTranslations = new Map<string, string>([
|
||||
["新建笔记本", "New notebook"],
|
||||
["创建笔记本", "Create notebook"],
|
||||
["笔记本名称", "Notebook name"],
|
||||
["顶层笔记本", "Top level"],
|
||||
["位于「{{name}}」下", "Under {{name}}"],
|
||||
["暂无笔记本", "No notebooks"],
|
||||
["先创建一个笔记本,再开始记录。", "Create a notebook to start taking notes."],
|
||||
["创建笔记本失败,请重试", "Could not create the notebook. Try again."],
|
||||
["连接尚未就绪", "Connection is not ready"],
|
||||
["笔记本已创建", "Notebook created"],
|
||||
["本机缓存更新失败,正在重新同步。", "The local cache could not update. Syncing again now."],
|
||||
["本级暂无笔记", "No notes directly in this notebook"],
|
||||
["无法保存“父笔记本中显示子笔记本笔记”设置,请稍后重试", "Could not save the \"Show notes from sub-notebooks\" setting. Please try again."],
|
||||
["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“父笔记本中显示子笔记本笔记”。", "Its sub-notebooks still contain {{count}} notes. Open a sub-notebook to see them, or turn on \"Show notes from sub-notebooks\" in Settings."],
|
||||
@@ -208,6 +219,17 @@ const mobileOnlyTranslations = new Map<string, string>([
|
||||
]);
|
||||
|
||||
const mobileOnlyJapanese = new Map<string, string>([
|
||||
["新建笔记本", "新しいノートブック"],
|
||||
["创建笔记本", "ノートブックを作成"],
|
||||
["笔记本名称", "ノートブック名"],
|
||||
["顶层笔记本", "最上位"],
|
||||
["位于「{{name}}」下", "「{{name}}」の下"],
|
||||
["暂无笔记本", "ノートブックがありません"],
|
||||
["先创建一个笔记本,再开始记录。", "ノートブックを作成して記録を始めましょう。"],
|
||||
["创建笔记本失败,请重试", "ノートブックを作成できませんでした。再試行してください。"],
|
||||
["连接尚未就绪", "接続の準備ができていません"],
|
||||
["笔记本已创建", "ノートブックを作成しました"],
|
||||
["本机缓存更新失败,正在重新同步。", "端末のキャッシュを更新できませんでした。再同期しています。"],
|
||||
["本级暂无笔记", "このノートブック直下にノートはありません"],
|
||||
["无法保存“父笔记本中显示子笔记本笔记”设置,请稍后重试", "「サブノートブックのノートを表示」の設定を保存できませんでした。しばらくしてから再試行してください。"],
|
||||
["子笔记本中还有 {{count}} 条笔记。可以打开子笔记本查看,或在设置中开启“父笔记本中显示子笔记本笔记”。", "サブノートブックにはまだ {{count}} 件のノートがあります。サブノートブックを開くか、設定で「サブノートブックのノートを表示」をオンにしてください。"],
|
||||
|
||||
@@ -197,16 +197,18 @@ export const NotesView = ({
|
||||
</View>
|
||||
|
||||
<MemoList
|
||||
emptyActions={memoView === "notebook" && notebooks.length > 0 && !searchActive && !filterActive
|
||||
? [
|
||||
{ label: "新建笔记", onPress: onCreate, variant: "primary" as const },
|
||||
...(onCreateFromTemplate
|
||||
? [{ label: "从模板新建", onPress: onCreateFromTemplate, variant: "secondary" as const }]
|
||||
: []),
|
||||
]
|
||||
emptyActions={memoView === "notebook" && !searchActive && !filterActive
|
||||
? notebooks.length === 0
|
||||
? [{ label: "新建笔记本", onPress: onOpenNotebookPicker, variant: "primary" as const }]
|
||||
: [
|
||||
{ label: "新建笔记", onPress: onCreate, variant: "primary" as const },
|
||||
...(onCreateFromTemplate
|
||||
? [{ label: "从模板新建", onPress: onCreateFromTemplate, variant: "secondary" as const }]
|
||||
: []),
|
||||
]
|
||||
: undefined}
|
||||
emptyDescription={hiddenDescendantMemoCount > 0 ? `子笔记本中还有 ${hiddenDescendantMemoCount} 条笔记。可以打开子笔记本查看,或在设置中开启“父笔记本中显示子笔记本笔记”。` : searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"}
|
||||
emptyTitle={hiddenDescendantMemoCount > 0 ? "本级暂无笔记" : searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : "暂无笔记"}
|
||||
emptyDescription={hiddenDescendantMemoCount > 0 ? `子笔记本中还有 ${hiddenDescendantMemoCount} 条笔记。可以打开子笔记本查看,或在设置中开启“父笔记本中显示子笔记本笔记”。` : searchActive ? "换个关键词再试" : filterActive ? "试试切换筛选条件,或调整搜索关键词。" : memoView === "trash" ? "删除的笔记会显示在这里。" : notebooks.length === 0 ? "先创建一个笔记本,再开始记录。" : "先创建一条笔记,之后可以在这里快速预览、搜索和批量整理。"}
|
||||
emptyTitle={hiddenDescendantMemoCount > 0 ? "本级暂无笔记" : searchActive ? "没有找到匹配笔记" : filterActive ? "没有符合筛选的笔记" : memoView === "trash" ? "回收站为空" : notebooks.length === 0 ? "暂无笔记本" : "暂无笔记"}
|
||||
error={error}
|
||||
initialSyncProgress={initialSyncProgress}
|
||||
isError={isError}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Plus,
|
||||
Search,
|
||||
Tag,
|
||||
TagPlus,
|
||||
@@ -89,6 +90,7 @@ export const NotebookPickerModal = ({
|
||||
includeAllNotes = true,
|
||||
notebooks,
|
||||
onClose,
|
||||
onCreate,
|
||||
onSelect,
|
||||
visible,
|
||||
}: {
|
||||
@@ -96,12 +98,19 @@ export const NotebookPickerModal = ({
|
||||
includeAllNotes?: boolean;
|
||||
notebooks: Notebook[];
|
||||
onClose: () => void;
|
||||
onCreate?: (name: string, parentId: string | null) => Promise<string>;
|
||||
onSelect: (notebookId: string) => void;
|
||||
visible: boolean;
|
||||
}) => {
|
||||
const { translate } = useMobileLocale();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newNotebookName, setNewNotebookName] = useState("");
|
||||
const [newNotebookParentId, setNewNotebookParentId] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [isSavingNotebook, setIsSavingNotebook] = useState(false);
|
||||
const createInFlight = useRef(false);
|
||||
const [collapsedNotebookIds, setCollapsedNotebookIds] = useState<Set<string>>(() => new Set());
|
||||
const selectedScroll = useAutoCenterSelectedScrollRow(visible, activeNotebookId);
|
||||
const notebookOptions = flattenNotebooks(notebooks);
|
||||
@@ -119,10 +128,33 @@ export const NotebookPickerModal = ({
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchText("");
|
||||
setCreating(false);
|
||||
setNewNotebookName("");
|
||||
setNewNotebookParentId(null);
|
||||
setCreateError("");
|
||||
setCollapsedNotebookIds(new Set(Array.from(childNotebookIds).filter((notebookId) => !activeNotebookAncestorIds.has(notebookId))));
|
||||
}
|
||||
}, [visible, activeNotebookId, notebooks]);
|
||||
|
||||
const submitNewNotebook = async () => {
|
||||
const name = newNotebookName.trim();
|
||||
if (!onCreate || createInFlight.current || !name || name.length > 80) {
|
||||
return;
|
||||
}
|
||||
createInFlight.current = true;
|
||||
setIsSavingNotebook(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
const notebookId = await onCreate(name, newNotebookParentId);
|
||||
onSelect(notebookId);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建笔记本失败,请重试");
|
||||
} finally {
|
||||
createInFlight.current = false;
|
||||
setIsSavingNotebook(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotebookCollapsed = (notebookId: string) => {
|
||||
setCollapsedNotebookIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -156,6 +188,34 @@ export const NotebookPickerModal = ({
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{onCreate ? (
|
||||
<View style={styles.notebookCreateWrap}>
|
||||
<Pressable accessibilityLabel="新建笔记本" accessibilityRole="button" onPress={() => { setCreating((value) => !value); setCreateError(""); }} style={styles.notebookCreateToggle}>
|
||||
<Plus color="#059669" size={18} />
|
||||
<Text style={styles.notebookCreateToggleText}>新建笔记本</Text>
|
||||
</Pressable>
|
||||
{creating ? (
|
||||
<View style={styles.notebookCreateForm}>
|
||||
<TextInput accessibilityLabel="笔记本名称" autoFocus maxLength={80} onChangeText={setNewNotebookName} placeholder="笔记本名称" placeholderTextColor="#94a3b8" returnKeyType="done" style={styles.notebookCreateInput} value={newNotebookName} />
|
||||
<View style={styles.notebookCreateParentRow}>
|
||||
<Pressable accessibilityRole="button" accessibilityState={{ selected: newNotebookParentId === null }} onPress={() => setNewNotebookParentId(null)} style={[styles.notebookCreateParentChoice, newNotebookParentId === null && styles.notebookCreateParentChoiceActive]}>
|
||||
<Text style={styles.notebookCreateParentText}>顶层笔记本</Text>
|
||||
</Pressable>
|
||||
{activeNotebookId !== ALL_NOTES_ID ? (
|
||||
<Pressable accessibilityRole="button" accessibilityState={{ selected: newNotebookParentId === activeNotebookId }} onPress={() => setNewNotebookParentId(activeNotebookId)} style={[styles.notebookCreateParentChoice, newNotebookParentId === activeNotebookId && styles.notebookCreateParentChoiceActive]}>
|
||||
<Text numberOfLines={1} style={styles.notebookCreateParentText}>{translate(`位于「${activeNotebookName}」下`)}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{createError ? <Text accessibilityRole="alert" style={styles.notebookCreateError}>{createError}</Text> : null}
|
||||
<Pressable accessibilityLabel="创建笔记本" accessibilityRole="button" accessibilityState={{ disabled: !newNotebookName.trim() || isSavingNotebook }} disabled={!newNotebookName.trim() || isSavingNotebook} onPress={() => void submitNewNotebook()} style={styles.notebookCreateSubmit}>
|
||||
{isSavingNotebook ? <ActivityIndicator color="#ffffff" size="small" /> : <Text style={styles.notebookCreateSubmitText}>创建笔记本</Text>}
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.notebookPickerContent}
|
||||
onLayout={selectedScroll.onViewportLayout}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Alert, Pressable, Text } from "../components/LocalizedText";
|
||||
import { ApiRequestError } from "@edgeever/client";
|
||||
import { DEFAULT_MEMO_TITLE, getNotebookScopeIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail } from "@edgeever/shared";
|
||||
import { DEFAULT_MEMO_TITLE, getNotebookScopeIds, hasDiagramDocumentMarker, markdownToDoc, type MemoDetail, type Notebook } from "@edgeever/shared";
|
||||
import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui";
|
||||
import { clearMobileMemoDraft, readMobileMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts";
|
||||
import {
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getLocalMemo,
|
||||
listLocalMemos,
|
||||
listLocalNotebooks,
|
||||
upsertLocalNotebook,
|
||||
listLocalTags,
|
||||
resolveLocalMemo,
|
||||
syncMobileLocalMirror,
|
||||
@@ -1342,6 +1343,25 @@ export const WorkspaceScreen = ({
|
||||
activeNotebookId={activeNotebookId}
|
||||
notebooks={notebooks}
|
||||
onClose={() => setNotebookPickerOpen(false)}
|
||||
onCreate={async (name, parentId) => {
|
||||
if (!client) {
|
||||
throw new Error("连接尚未就绪");
|
||||
}
|
||||
const { notebook } = await client.createNotebook({ name, parentId });
|
||||
queryClient.setQueryData<{ notebooks: Notebook[] }>(["mobile", "notebooks"], (current) => ({
|
||||
notebooks: [...(current?.notebooks ?? []).filter((item) => item.id !== notebook.id), notebook],
|
||||
}));
|
||||
try {
|
||||
await upsertLocalNotebook(dataScope, notebook);
|
||||
void queryClient.invalidateQueries({ queryKey: ["mobile", "notebooks"] });
|
||||
} catch {
|
||||
Alert.alert("笔记本已创建", "本机缓存更新失败,正在重新同步。", [{ text: "好的" }]);
|
||||
void syncMobileLocalMirror(client, dataScope)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ["mobile", "notebooks"] }))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return notebook.id;
|
||||
}}
|
||||
onSelect={(notebookId) => {
|
||||
setSelectedTag(null);
|
||||
setActiveNotebookId(notebookId);
|
||||
|
||||
@@ -1541,6 +1541,73 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
},
|
||||
notebookCreateWrap: {
|
||||
borderBottomColor: "#e2e8f0",
|
||||
borderBottomWidth: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
notebookCreateToggle: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
notebookCreateToggleText: {
|
||||
color: "#059669",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
notebookCreateForm: {
|
||||
gap: 8,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
notebookCreateInput: {
|
||||
borderColor: "#cbd5e1",
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: "#0f172a",
|
||||
fontSize: 15,
|
||||
minHeight: 42,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
notebookCreateParentRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
notebookCreateParentChoice: {
|
||||
borderColor: "#cbd5e1",
|
||||
borderRadius: 7,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
minHeight: 36,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
notebookCreateParentChoiceActive: {
|
||||
borderColor: "#059669",
|
||||
backgroundColor: "#ecfdf5",
|
||||
},
|
||||
notebookCreateParentText: {
|
||||
color: "#0f172a",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
notebookCreateError: {
|
||||
color: "#dc2626",
|
||||
fontSize: 12,
|
||||
},
|
||||
notebookCreateSubmit: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#059669",
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
minHeight: 40,
|
||||
},
|
||||
notebookCreateSubmitText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 14,
|
||||
fontWeight: "700",
|
||||
},
|
||||
notebookPickerSectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
|
||||
Reference in new issue
Block a user