更新内容
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://49.232.185.154:3002
|
||||||
@@ -6,4 +6,7 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.android.example
|
||||||
saves/*.json
|
saves/*.json
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
|||||||
@@ -1,20 +1,130 @@
|
|||||||
<div align="center">
|
# 言の葉写字板
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Run and deploy your AI Studio app
|
一个用于歌词/日语文本抄写、假名标注和注释管理的写字板。
|
||||||
|
|
||||||
This contains everything you need to run your app locally.
|
## 本地开发
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/d4d25382-dcc2-43cb-97a3-541137bcfafe
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## Run Locally
|
本地开发地址默认是:
|
||||||
|
|
||||||
**Prerequisites:** Node.js
|
```text
|
||||||
|
http://localhost:3001/
|
||||||
|
```
|
||||||
|
|
||||||
|
开发模式下,`vite.config.ts` 会提供本地 `/api/saves`,文档保存在项目根目录的 `saves/`。
|
||||||
|
|
||||||
1. Install dependencies:
|
## 生产运行
|
||||||
`npm install`
|
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
生产环境使用独立 Express 服务:
|
||||||
3. Run the app:
|
|
||||||
`npm run dev`
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://0.0.0.0:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
可用环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3002
|
||||||
|
HOST=0.0.0.0
|
||||||
|
SAVES_DIR=/opt/kotonoha/data/saves
|
||||||
|
```
|
||||||
|
|
||||||
|
生产服务会:
|
||||||
|
|
||||||
|
- 提供 `/api/saves` 文档保存 API
|
||||||
|
- 读写 `SAVES_DIR` 指向的 JSON 文档目录
|
||||||
|
- 静态托管 `dist/` 前端文件
|
||||||
|
- 对非 `/api` 路径返回前端入口,支持刷新页面
|
||||||
|
|
||||||
|
## 1Panel 部署建议
|
||||||
|
|
||||||
|
推荐服务器目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/kotonoha
|
||||||
|
```
|
||||||
|
|
||||||
|
上传项目后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kotonoha
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
mkdir -p /opt/kotonoha/data/saves
|
||||||
|
```
|
||||||
|
|
||||||
|
Node 应用配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
项目目录:/opt/kotonoha
|
||||||
|
启动命令:npm run start
|
||||||
|
端口:3002
|
||||||
|
环境变量:
|
||||||
|
PORT=3002
|
||||||
|
HOST=0.0.0.0
|
||||||
|
SAVES_DIR=/opt/kotonoha/data/saves
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenResty / 网站反向代理:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/ -> http://127.0.0.1:3002
|
||||||
|
/ -> http://127.0.0.1:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
数据备份重点:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/kotonoha/data/saves
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android App 打包
|
||||||
|
|
||||||
|
本项目使用 Capacitor 生成 Android 工程。
|
||||||
|
|
||||||
|
Android 测试包会读取 `.env.android`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VITE_API_BASE_URL=http://49.232.185.154:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
如果以后换成域名和 HTTPS,改这里即可:
|
||||||
|
|
||||||
|
```text
|
||||||
|
VITE_API_BASE_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
生成/同步 Android 工程:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:android
|
||||||
|
npm run cap:sync
|
||||||
|
npm run cap:open
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 Android Studio 里:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Build -> Build Bundle(s) / APK(s) -> Build APK(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
当前测试版使用 HTTP 访问服务器,`android/app/src/main/AndroidManifest.xml` 已开启:
|
||||||
|
|
||||||
|
```text
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
以后改成 HTTPS 后可以关闭这个配置。
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-06-30T10:13:32.070910Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\TU\.android\avd\Pixel_6_Pro_API_36.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.yiulix.kotonoha"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.yiulix.kotonoha"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.yiulix.kotonoha;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {}
|
||||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Kotonoha</string>
|
||||||
|
<string name="title_activity_main">Kotonoha</string>
|
||||||
|
<string name="package_name">com.yiulix.kotonoha</string>
|
||||||
|
<string name="custom_url_scheme">com.yiulix.kotonoha</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||||
|
<!-- Customize your theme here. -->
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
|
<item name="android:background">@null</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<item name="android:background">@drawable/splash</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
public class ExampleUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void addition_isCorrect() throws Exception {
|
||||||
|
assertEquals(4, 2 + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||||
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
// in the individual module build.gradle files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "variables.gradle"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
include ':capacitor-android'
|
||||||
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx1536m
|
||||||
|
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include ':app'
|
||||||
|
include ':capacitor-cordova-android-plugins'
|
||||||
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|
||||||
|
apply from: 'capacitor.settings.gradle'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ext {
|
||||||
|
minSdkVersion = 24
|
||||||
|
compileSdkVersion = 36
|
||||||
|
targetSdkVersion = 36
|
||||||
|
androidxActivityVersion = '1.11.0'
|
||||||
|
androidxAppCompatVersion = '1.7.1'
|
||||||
|
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||||
|
androidxCoreVersion = '1.17.0'
|
||||||
|
androidxFragmentVersion = '1.8.9'
|
||||||
|
coreSplashScreenVersion = '1.2.0'
|
||||||
|
androidxWebkitVersion = '1.14.0'
|
||||||
|
junitVersion = '4.13.2'
|
||||||
|
androidxJunitVersion = '1.3.0'
|
||||||
|
androidxEspressoCoreVersion = '3.7.0'
|
||||||
|
cordovaAndroidVersion = '14.0.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.yiulix.kotonoha',
|
||||||
|
appName: '言の葉写字板',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
androidScheme: 'http',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Google AI Studio App</title>
|
<title>言の葉写字板</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Japanese Writing Pad",
|
"name": "言の葉写字板",
|
||||||
"description": "An interactive, spacious Japanese writing pad with line-by-line furigana tagging and visual text-selection annotations.",
|
"description": "用于歌词抄写、假名标注和词义记录的写字板。",
|
||||||
"requestFramePermissions": [],
|
"requestFramePermissions": []
|
||||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,43 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
"dev": "vite --port=3001 --host=0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:android": "vite build --mode android",
|
||||||
|
"cap:sync": "capacitor sync android",
|
||||||
|
"cap:open": "capacitor open android",
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"migrate:saves": "node server/migrate-saves.js",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist server.js",
|
"clean": "rm -rf dist server.js",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@capacitor/android": "^8.4.1",
|
||||||
|
"@capacitor/cli": "^8.4.1",
|
||||||
|
"@capacitor/core": "^8.4.1",
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3"
|
||||||
"express": "^4.21.2",
|
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"motion": "^12.23.24"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@vitejs/plugin-legacy": "^6.1.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
|
"terser": "^5.48.0",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3"
|
||||||
"@types/express": "^4.17.21"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { existsSync, statSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(PROJECT_ROOT, 'data'));
|
||||||
|
const DB_PATH = path.resolve(process.env.SQLITE_DB || path.join(DATA_DIR, 'kotonoha.sqlite'));
|
||||||
|
|
||||||
|
let database;
|
||||||
|
|
||||||
|
export const sanitizeSaveName = (name) => {
|
||||||
|
const cleaned = String(name || '')
|
||||||
|
.replace(/\.json$/i, '')
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||||
|
.trim();
|
||||||
|
return `${cleaned || `save-${Date.now()}`}.json`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTerm = (text) => String(text || '').trim();
|
||||||
|
|
||||||
|
const safeParseJson = (value, fallback) => {
|
||||||
|
try {
|
||||||
|
return value ? JSON.parse(value) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureDatabase = () => {
|
||||||
|
if (database) return database;
|
||||||
|
|
||||||
|
if (!existsSync(DATA_DIR)) {
|
||||||
|
// better-sqlite3 is synchronous, so create the folder synchronously via mkdir from fs/promises is not suitable here.
|
||||||
|
throw new Error(`Database directory does not exist: ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
database = new Database(DB_PATH);
|
||||||
|
database.pragma('journal_mode = WAL');
|
||||||
|
database.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_name TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
document_json TEXT NOT NULL,
|
||||||
|
saved_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
line_id TEXT NOT NULL,
|
||||||
|
line_index INTEGER NOT NULL,
|
||||||
|
raw_text TEXT NOT NULL DEFAULT '',
|
||||||
|
furigana_items_json TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_id TEXT NOT NULL,
|
||||||
|
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
line_id TEXT NOT NULL,
|
||||||
|
line_index INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
normalized_text TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT 'yellow',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
line_text TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vocab_terms (
|
||||||
|
normalized_text TEXT PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_normalized_text ON notes(normalized_text);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_document_id ON notes(document_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lines_document_id ON lines(document_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
return database;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initDatabase = async () => {
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
return ensureDatabase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertVocabTerms = (db, notes) => {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO vocab_terms (normalized_text, text, updated_at)
|
||||||
|
VALUES (@normalizedText, @text, @updatedAt)
|
||||||
|
ON CONFLICT(normalized_text) DO UPDATE SET
|
||||||
|
text = excluded.text,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
if (!note.normalizedText) continue;
|
||||||
|
stmt.run({
|
||||||
|
normalizedText: note.normalizedText,
|
||||||
|
text: note.text,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertDocumentChildren = (db, documentId, document) => {
|
||||||
|
const lines = Array.isArray(document?.lines) ? document.lines : [];
|
||||||
|
const insertLine = db.prepare(`
|
||||||
|
INSERT INTO lines (document_id, line_id, line_index, raw_text, furigana_items_json)
|
||||||
|
VALUES (@documentId, @lineId, @lineIndex, @rawText, @furiganaItemsJson)
|
||||||
|
`);
|
||||||
|
const insertNote = db.prepare(`
|
||||||
|
INSERT INTO notes (
|
||||||
|
note_id, document_id, line_id, line_index, text, normalized_text, comment, color, created_at, line_text
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@noteId, @documentId, @lineId, @lineIndex, @text, @normalizedText, @comment, @color, @createdAt, @lineText
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
const allNotes = [];
|
||||||
|
|
||||||
|
lines.forEach((line, lineIndex) => {
|
||||||
|
const lineId = String(line?.id || `line-${lineIndex}`);
|
||||||
|
const rawText = typeof line?.rawText === 'string' ? line.rawText : '';
|
||||||
|
const furiganaItems = Array.isArray(line?.furiganaItems) ? line.furiganaItems : [];
|
||||||
|
|
||||||
|
insertLine.run({
|
||||||
|
documentId,
|
||||||
|
lineId,
|
||||||
|
lineIndex,
|
||||||
|
rawText,
|
||||||
|
furiganaItemsJson: JSON.stringify(furiganaItems),
|
||||||
|
});
|
||||||
|
|
||||||
|
const notes = Array.isArray(line?.notes) ? line.notes : [];
|
||||||
|
notes.forEach((note, noteIndex) => {
|
||||||
|
const text = String(note?.text || '').trim();
|
||||||
|
const normalizedText = normalizeTerm(text);
|
||||||
|
if (!normalizedText) return;
|
||||||
|
|
||||||
|
const noteRow = {
|
||||||
|
noteId: String(note?.id || `note-${lineIndex}-${noteIndex}`),
|
||||||
|
documentId,
|
||||||
|
lineId,
|
||||||
|
lineIndex,
|
||||||
|
text,
|
||||||
|
normalizedText,
|
||||||
|
comment: String(note?.comment || ''),
|
||||||
|
color: String(note?.color || 'yellow'),
|
||||||
|
createdAt: Number(note?.createdAt || Date.now()),
|
||||||
|
lineText: rawText,
|
||||||
|
};
|
||||||
|
insertNote.run(noteRow);
|
||||||
|
allNotes.push(noteRow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
upsertVocabTerms(db, allNotes);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDocument = async ({ name, document }) => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
const fileName = sanitizeSaveName(name || document?.title || 'current');
|
||||||
|
const title =
|
||||||
|
typeof document?.title === 'string' && document.title.trim()
|
||||||
|
? document.title.trim()
|
||||||
|
: fileName.replace(/\.json$/i, '');
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const documentJson = JSON.stringify(document || {});
|
||||||
|
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
const existing = db.prepare('SELECT id FROM documents WHERE file_name = ?').get(fileName);
|
||||||
|
let documentId = existing?.id;
|
||||||
|
|
||||||
|
if (documentId) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE documents
|
||||||
|
SET title = ?, document_json = ?, saved_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(title, documentJson, now, now, documentId);
|
||||||
|
db.prepare('DELETE FROM lines WHERE document_id = ?').run(documentId);
|
||||||
|
db.prepare('DELETE FROM notes WHERE document_id = ?').run(documentId);
|
||||||
|
} else {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO documents (file_name, title, document_json, saved_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(fileName, title, documentJson, now, now, now);
|
||||||
|
documentId = Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertDocumentChildren(db, documentId, document);
|
||||||
|
return documentId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = transaction();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fileName,
|
||||||
|
title,
|
||||||
|
savedAt: now,
|
||||||
|
size: Buffer.byteLength(documentJson, 'utf-8'),
|
||||||
|
lineCount: Array.isArray(document?.lines) ? document.lines.length : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDocumentByFileName = async (fileName) => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
db.prepare('DELETE FROM documents WHERE file_name = ?').run(sanitizeSaveName(fileName));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDocuments = async () => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.file_name AS name,
|
||||||
|
d.title,
|
||||||
|
d.saved_at AS savedAt,
|
||||||
|
LENGTH(d.document_json) AS size,
|
||||||
|
COUNT(l.id) AS lineCount
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN lines l ON l.document_id = d.id
|
||||||
|
GROUP BY d.id
|
||||||
|
ORDER BY datetime(d.saved_at) DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
lineCount: Number(row.lineCount || 0),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocumentByFileName = async (fileName) => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
const row = db.prepare('SELECT document_json FROM documents WHERE file_name = ?').get(sanitizeSaveName(fileName));
|
||||||
|
return row ? safeParseJson(row.document_json, null) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocumentById = async (id) => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
const row = db.prepare('SELECT document_json FROM documents WHERE id = ?').get(Number(id));
|
||||||
|
return row ? safeParseJson(row.document_json, null) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVocabCards = async () => {
|
||||||
|
await initDatabase();
|
||||||
|
const db = ensureDatabase();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
n.normalized_text AS normalizedText,
|
||||||
|
n.text,
|
||||||
|
n.comment,
|
||||||
|
n.color,
|
||||||
|
n.line_text AS lineText,
|
||||||
|
n.line_index AS lineIndex,
|
||||||
|
n.created_at AS createdAt,
|
||||||
|
d.title AS documentTitle,
|
||||||
|
d.file_name AS fileName,
|
||||||
|
d.id AS documentId
|
||||||
|
FROM notes n
|
||||||
|
JOIN documents d ON d.id = n.document_id
|
||||||
|
ORDER BY n.normalized_text ASC, n.created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.normalizedText;
|
||||||
|
if (!grouped.has(key)) {
|
||||||
|
grouped.set(key, {
|
||||||
|
text: row.text,
|
||||||
|
normalizedText: key,
|
||||||
|
noteCount: 0,
|
||||||
|
documentTitles: [],
|
||||||
|
latestComment: row.comment,
|
||||||
|
latestColor: row.color,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = grouped.get(key);
|
||||||
|
card.noteCount += 1;
|
||||||
|
if (!card.documentTitles.includes(row.documentTitle)) {
|
||||||
|
card.documentTitles.push(row.documentTitle);
|
||||||
|
}
|
||||||
|
card.entries.push({
|
||||||
|
comment: row.comment,
|
||||||
|
color: row.color,
|
||||||
|
lineText: row.lineText,
|
||||||
|
lineNumber: Number(row.lineIndex) + 1,
|
||||||
|
documentTitle: row.documentTitle,
|
||||||
|
fileName: row.fileName,
|
||||||
|
documentId: row.documentId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const migrateSavesToDatabase = async (savesDir) => {
|
||||||
|
await initDatabase();
|
||||||
|
await fs.mkdir(savesDir, { recursive: true });
|
||||||
|
const entries = await fs.readdir(savesDir);
|
||||||
|
const migrated = [];
|
||||||
|
|
||||||
|
for (const entry of entries.filter((item) => item.toLowerCase().endsWith('.json'))) {
|
||||||
|
const filePath = path.join(savesDir, entry);
|
||||||
|
try {
|
||||||
|
const document = safeParseJson(await fs.readFile(filePath, 'utf-8'), null);
|
||||||
|
if (!document || typeof document !== 'object') continue;
|
||||||
|
|
||||||
|
const stat = statSync(filePath);
|
||||||
|
const file = await saveDocument({
|
||||||
|
name: entry,
|
||||||
|
document: {
|
||||||
|
...document,
|
||||||
|
exportedAt: document.exportedAt || stat.mtime.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
migrated.push(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to migrate save file: ${entry}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDatabasePath = () => DB_PATH;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import {
|
||||||
|
deleteDocumentByFileName,
|
||||||
|
getDatabasePath,
|
||||||
|
getDocumentByFileName,
|
||||||
|
getDocumentById,
|
||||||
|
getVocabCards,
|
||||||
|
initDatabase,
|
||||||
|
listDocuments,
|
||||||
|
migrateSavesToDatabase,
|
||||||
|
sanitizeSaveName,
|
||||||
|
saveDocument,
|
||||||
|
} from './db.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const PORT = Number(process.env.PORT || 3002);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const DIST_DIR = path.resolve(PROJECT_ROOT, 'dist');
|
||||||
|
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.sendStatus(204);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
const ensureSavesDir = async () => {
|
||||||
|
await fs.mkdir(SAVES_DIR, { recursive: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSaveFilePath = (name) => path.join(SAVES_DIR, sanitizeSaveName(name));
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
savesDir: SAVES_DIR,
|
||||||
|
database: getDatabasePath(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/saves', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureSavesDir();
|
||||||
|
const files = await listDocuments();
|
||||||
|
res.json({ files });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list save files', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list save files' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/saves/:fileName', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureSavesDir();
|
||||||
|
const document = await getDocumentByFileName(req.params.fileName);
|
||||||
|
if (document) {
|
||||||
|
res.json(document);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = getSaveFilePath(req.params.fileName);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
res.status(404).json({ error: 'Save file not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
res.type('application/json').send(content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read save file', error);
|
||||||
|
res.status(500).json({ error: 'Failed to read save file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/saves', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureSavesDir();
|
||||||
|
const fileName = sanitizeSaveName(req.body?.name || 'current');
|
||||||
|
const document = req.body?.document;
|
||||||
|
|
||||||
|
if (!document || typeof document !== 'object') {
|
||||||
|
res.status(400).json({ error: 'Missing document payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
|
||||||
|
const file = await saveDocument({ name: fileName, document });
|
||||||
|
res.json({ file });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save document', error);
|
||||||
|
res.status(500).json({ error: 'Failed to save document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/saves/:fileName', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureSavesDir();
|
||||||
|
const filePath = getSaveFilePath(req.params.fileName);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
res.status(404).json({ error: 'Save file not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
await deleteDocumentByFileName(req.params.fileName);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete save file', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete save file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/documents', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const files = await listDocuments();
|
||||||
|
res.json({ files });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list documents', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list documents' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/documents/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const document = await getDocumentById(req.params.id);
|
||||||
|
if (!document) {
|
||||||
|
res.status(404).json({ error: 'Document not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(document);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read document', error);
|
||||||
|
res.status(500).json({ error: 'Failed to read document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/documents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const document = req.body?.document;
|
||||||
|
if (!document || typeof document !== 'object') {
|
||||||
|
res.status(400).json({ error: 'Missing document payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await saveDocument({ name: req.body?.name || document.title || 'current', document });
|
||||||
|
await ensureSavesDir();
|
||||||
|
await fs.writeFile(path.join(SAVES_DIR, file.name), JSON.stringify(document, null, 2), 'utf-8');
|
||||||
|
res.json({ file });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save document', error);
|
||||||
|
res.status(500).json({ error: 'Failed to save document' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/vocab', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const cards = await getVocabCards();
|
||||||
|
res.json({ cards });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list vocab cards', error);
|
||||||
|
res.status(500).json({ error: 'Failed to list vocab cards' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/vocab/:term', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const cards = await getVocabCards();
|
||||||
|
const card = cards.find((item) => item.normalizedText === req.params.term || item.text === req.params.term);
|
||||||
|
if (!card) {
|
||||||
|
res.status(404).json({ error: 'Vocab card not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ card });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read vocab card', error);
|
||||||
|
res.status(500).json({ error: 'Failed to read vocab card' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existsSync(DIST_DIR)) {
|
||||||
|
app.use(express.static(DIST_DIR));
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api/')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.status(503).send('Frontend dist not found. Run `npm run build` before starting the server.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await initDatabase();
|
||||||
|
await ensureSavesDir();
|
||||||
|
const migratedFiles = await migrateSavesToDatabase(SAVES_DIR);
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`Kotonoha server listening on http://${HOST}:${PORT}`);
|
||||||
|
console.log(`Serving frontend from: ${DIST_DIR}`);
|
||||||
|
console.log(`Using saves directory: ${SAVES_DIR}`);
|
||||||
|
console.log(`Using SQLite database: ${getDatabasePath()}`);
|
||||||
|
console.log(`Indexed save files: ${migratedFiles.length}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { getDatabasePath, migrateSavesToDatabase } from './db.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const migrated = await migrateSavesToDatabase(SAVES_DIR);
|
||||||
|
console.log(`Migrated ${migrated.length} save files into SQLite.`);
|
||||||
|
console.log(`SQLite database: ${getDatabasePath()}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to migrate saves into SQLite.', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { FuriganaItem, WritingLine, SidebarNote } from './types';
|
import { FuriganaItem, WritingLine, SidebarNote } from './types';
|
||||||
import { Toolbar } from './components/Toolbar';
|
import { Toolbar } from './components/Toolbar';
|
||||||
import { LineItem } from './components/LineItem';
|
import { LineItem } from './components/LineItem';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar, SavedDocumentNote } from './components/Sidebar';
|
||||||
|
import { AppNav, AppView } from './components/AppNav';
|
||||||
|
import { VocabCards } from './components/VocabCards';
|
||||||
import { adjustFuriganaItemsForTextChange, normalizeLine, normalizeLines } from './utils';
|
import { adjustFuriganaItemsForTextChange, normalizeLine, normalizeLines } from './utils';
|
||||||
import { BookOpen, Plus, X } from 'lucide-react';
|
import { apiUrl } from './api';
|
||||||
|
import { Check, Copy, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
// Default Demo Content Definitions
|
// Default Demo Content Definitions
|
||||||
const DEMOS = {
|
const DEMOS = {
|
||||||
@@ -91,8 +94,16 @@ const STORAGE_KEY = 'jp_writing_pad_document_v5';
|
|||||||
const LEGACY_LINES_KEY = 'jp_writing_pad_lines_v4';
|
const LEGACY_LINES_KEY = 'jp_writing_pad_lines_v4';
|
||||||
const CONFIG_KEY = 'jp_writing_pad_config_v4';
|
const CONFIG_KEY = 'jp_writing_pad_config_v4';
|
||||||
const DEFAULT_DOCUMENT_TITLE = '未命名文档';
|
const DEFAULT_DOCUMENT_TITLE = '未命名文档';
|
||||||
|
const MOBILE_HIGHLIGHT_COLORS = [
|
||||||
|
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', check: 'text-[#2D2926]' },
|
||||||
|
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', check: 'text-rose-700' },
|
||||||
|
{ value: 'sky', label: '琉璃蓝', bg: 'bg-sky-100', check: 'text-sky-700' },
|
||||||
|
{ value: 'emerald', label: '薄荷绿', bg: 'bg-emerald-100', check: 'text-emerald-700' },
|
||||||
|
{ value: 'violet', label: '藤萝紫', bg: 'bg-purple-100', check: 'text-purple-700' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ServerSaveFile {
|
interface ServerSaveFile {
|
||||||
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
savedAt: string;
|
savedAt: string;
|
||||||
@@ -115,6 +126,57 @@ const removeEmptyFuriganaItems = (line: WritingLine): WritingLine => ({
|
|||||||
furiganaItems: line.furiganaItems.filter((item) => item.text.trim().length > 0),
|
furiganaItems: line.furiganaItems.filter((item) => item.text.trim().length > 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyNoteToLines = (
|
||||||
|
sourceLines: WritingLine[],
|
||||||
|
lineId: string,
|
||||||
|
noteText: string,
|
||||||
|
comment: string,
|
||||||
|
color: string,
|
||||||
|
noteId?: string
|
||||||
|
) =>
|
||||||
|
sourceLines.map((line) => {
|
||||||
|
if (line.id !== lineId) return line;
|
||||||
|
|
||||||
|
if (noteId) {
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
notes: line.notes.map((note) =>
|
||||||
|
note.id === noteId ? { ...note, text: noteText, comment, color } : note
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = line.notes.findIndex((note) => note.text === noteText);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
const updatedNotes = [...line.notes];
|
||||||
|
updatedNotes[existingIndex] = { ...updatedNotes[existingIndex], comment, color };
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
notes: updatedNotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNote: SidebarNote = {
|
||||||
|
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
||||||
|
text: noteText,
|
||||||
|
comment,
|
||||||
|
color,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
notes: [...line.notes, newNote],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getIsMobileViewport = () => {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
const isMobileUserAgent = /Android|iPhone|iPad|iPod|Mobile/i.test(window.navigator.userAgent);
|
||||||
|
const isSmallTouchViewport = window.matchMedia('(max-width: 767px) and (pointer: coarse)').matches;
|
||||||
|
return isMobileUserAgent || isSmallTouchViewport;
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [lines, setLines] = useState<WritingLine[]>([]);
|
const [lines, setLines] = useState<WritingLine[]>([]);
|
||||||
const paperStyle = 'blank';
|
const paperStyle = 'blank';
|
||||||
@@ -122,6 +184,9 @@ export default function App() {
|
|||||||
const [fontSize, setFontSize] = useState<number>(20);
|
const [fontSize, setFontSize] = useState<number>(20);
|
||||||
const lineSpacing = 4;
|
const lineSpacing = 4;
|
||||||
const showLineNumbers = false;
|
const showLineNumbers = false;
|
||||||
|
const [isMobileViewport, setIsMobileViewport] = useState<boolean>(getIsMobileViewport);
|
||||||
|
const canEditDocument = !isMobileViewport;
|
||||||
|
const [activeView, setActiveView] = useState<AppView>('lyrics');
|
||||||
|
|
||||||
// Focus and Selection States
|
// Focus and Selection States
|
||||||
const [activeLineId, setActiveLineId] = useState<string | null>(null);
|
const [activeLineId, setActiveLineId] = useState<string | null>(null);
|
||||||
@@ -142,8 +207,12 @@ export default function App() {
|
|||||||
const [boundFileName, setBoundFileName] = useState<string | null>(null);
|
const [boundFileName, setBoundFileName] = useState<string | null>(null);
|
||||||
const [fileStatus, setFileStatus] = useState<string>('未选择项目保存文件');
|
const [fileStatus, setFileStatus] = useState<string>('未选择项目保存文件');
|
||||||
const [serverSaveFiles, setServerSaveFiles] = useState<ServerSaveFile[]>([]);
|
const [serverSaveFiles, setServerSaveFiles] = useState<ServerSaveFile[]>([]);
|
||||||
|
const [savedDocumentNotes, setSavedDocumentNotes] = useState<SavedDocumentNote[]>([]);
|
||||||
const [saveNotice, setSaveNotice] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
const [saveNotice, setSaveNotice] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
const [mobileDrawerInteractive, setMobileDrawerInteractive] = useState<boolean>(false);
|
||||||
|
const [mobileSelectedColor, setMobileSelectedColor] = useState<string>('yellow');
|
||||||
const saveNoticeTimerRef = React.useRef<number | null>(null);
|
const saveNoticeTimerRef = React.useRef<number | null>(null);
|
||||||
|
const mobileDrawerTimerRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
// INITIAL LOAD
|
// INITIAL LOAD
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,14 +280,58 @@ export default function App() {
|
|||||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
}, [fontSize]);
|
}, [fontSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(max-width: 767px) and (pointer: coarse)');
|
||||||
|
const syncViewportMode = () => setIsMobileViewport(mediaQuery.matches);
|
||||||
|
|
||||||
|
syncViewportMode();
|
||||||
|
mediaQuery.addEventListener('change', syncViewportMode);
|
||||||
|
window.addEventListener('resize', syncViewportMode);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', syncViewportMode);
|
||||||
|
window.removeEventListener('resize', syncViewportMode);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canEditDocument) {
|
||||||
|
setEditingLineId(null);
|
||||||
|
setActiveLineId(null);
|
||||||
|
setSelectedFurigana(null);
|
||||||
|
setShowImportArea(false);
|
||||||
|
}
|
||||||
|
}, [canEditDocument]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (saveNoticeTimerRef.current) {
|
if (saveNoticeTimerRef.current) {
|
||||||
window.clearTimeout(saveNoticeTimerRef.current);
|
window.clearTimeout(saveNoticeTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (mobileDrawerTimerRef.current) {
|
||||||
|
window.clearTimeout(mobileDrawerTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileDrawerTimerRef.current) {
|
||||||
|
window.clearTimeout(mobileDrawerTimerRef.current);
|
||||||
|
mobileDrawerTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSelection) {
|
||||||
|
setMobileDrawerInteractive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMobileDrawerInteractive(false);
|
||||||
|
mobileDrawerTimerRef.current = window.setTimeout(() => {
|
||||||
|
setMobileDrawerInteractive(true);
|
||||||
|
mobileDrawerTimerRef.current = null;
|
||||||
|
}, 350);
|
||||||
|
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId]);
|
||||||
|
|
||||||
// DEACTIVATE EDIT MODE AND UNFOCUS NOTES ON CLICK-AWAY
|
// DEACTIVATE EDIT MODE AND UNFOCUS NOTES ON CLICK-AWAY
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDocumentClick = (e: MouseEvent) => {
|
const handleDocumentClick = (e: MouseEvent) => {
|
||||||
@@ -282,6 +395,8 @@ export default function App() {
|
|||||||
newRawText: string,
|
newRawText: string,
|
||||||
newFuriganaItems?: FuriganaItem[]
|
newFuriganaItems?: FuriganaItem[]
|
||||||
) => {
|
) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((l) => {
|
prev.map((l) => {
|
||||||
if (l.id === lineId) {
|
if (l.id === lineId) {
|
||||||
@@ -323,6 +438,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertBelow = (referenceLineId: string) => {
|
const handleInsertBelow = (referenceLineId: string) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
const newId = `line-${Date.now()}`;
|
const newId = `line-${Date.now()}`;
|
||||||
setLines((prev) => {
|
setLines((prev) => {
|
||||||
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
||||||
@@ -345,6 +462,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertAbove = (referenceLineId: string) => {
|
const handleInsertAbove = (referenceLineId: string) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
const newId = `line-${Date.now()}`;
|
const newId = `line-${Date.now()}`;
|
||||||
setLines((prev) => {
|
setLines((prev) => {
|
||||||
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
const idx = prev.findIndex((l) => l.id === referenceLineId);
|
||||||
@@ -367,6 +486,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLineAtEnd = () => {
|
const handleAddLineAtEnd = () => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
const newId = `line-${Date.now()}`;
|
const newId = `line-${Date.now()}`;
|
||||||
const newLine = createEmptyLine(newId);
|
const newLine = createEmptyLine(newId);
|
||||||
setLines((prev) => [...prev, newLine]);
|
setLines((prev) => [...prev, newLine]);
|
||||||
@@ -381,6 +502,8 @@ export default function App() {
|
|||||||
|
|
||||||
// SEAMLESS KEYBOARD NAVIGATION BETWEEN FIELDS
|
// SEAMLESS KEYBOARD NAVIGATION BETWEEN FIELDS
|
||||||
const moveFocusUp = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
const moveFocusUp = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
const idx = lines.findIndex((l) => l.id === currentLineId);
|
const idx = lines.findIndex((l) => l.id === currentLineId);
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
const prevLine = lines[idx - 1];
|
const prevLine = lines[idx - 1];
|
||||||
@@ -397,6 +520,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moveFocusDown = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
const moveFocusDown = (currentLineId: string, focusField: 'main' | 'furigana') => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
const idx = lines.findIndex((l) => l.id === currentLineId);
|
const idx = lines.findIndex((l) => l.id === currentLineId);
|
||||||
if (idx !== -1 && idx < lines.length - 1) {
|
if (idx !== -1 && idx < lines.length - 1) {
|
||||||
const nextLine = lines[idx + 1];
|
const nextLine = lines[idx + 1];
|
||||||
@@ -456,44 +581,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNote = (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => {
|
const handleAddNote = (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => {
|
||||||
setLines((prev) =>
|
setLines((prev) => applyNoteToLines(prev, lineId, noteText, comment, color, noteId));
|
||||||
prev.map((l) => {
|
|
||||||
if (l.id === lineId) {
|
|
||||||
if (noteId) {
|
|
||||||
return {
|
|
||||||
...l,
|
|
||||||
notes: l.notes.map((n) =>
|
|
||||||
n.id === noteId ? { ...n, text: noteText, comment, color } : n
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, if there is already a note with the exact same text, update it
|
|
||||||
const idx = l.notes.findIndex((n) => n.text === noteText);
|
|
||||||
if (idx !== -1) {
|
|
||||||
const updated = [...l.notes];
|
|
||||||
updated[idx] = { ...updated[idx], comment, color };
|
|
||||||
return {
|
|
||||||
...l,
|
|
||||||
notes: updated,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNote: SidebarNote = {
|
|
||||||
id: `note-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
|
||||||
text: noteText,
|
|
||||||
comment,
|
|
||||||
color,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...l,
|
|
||||||
notes: [...l.notes, newNote],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return l;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNote = (lineId: string, noteId: string) => {
|
const handleDeleteNote = (lineId: string, noteId: string) => {
|
||||||
@@ -511,6 +599,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFuriganaSelect = (lineId: string, itemId: string) => {
|
const handleFuriganaSelect = (lineId: string, itemId: string) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
lastSelectionTimeRef.current = Date.now();
|
lastSelectionTimeRef.current = Date.now();
|
||||||
setFocusedNoteId(null);
|
setFocusedNoteId(null);
|
||||||
setCurrentSelection(null);
|
setCurrentSelection(null);
|
||||||
@@ -519,6 +609,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFurigana = (lineId: string, itemId: string) => {
|
const handleDeleteFurigana = (lineId: string, itemId: string) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((line) => {
|
prev.map((line) => {
|
||||||
if (line.id !== lineId) return line;
|
if (line.id !== lineId) return line;
|
||||||
@@ -533,6 +625,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateFurigana = (lineId: string, itemId: string, text: string) => {
|
const handleUpdateFurigana = (lineId: string, itemId: string, text: string) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((line) => {
|
prev.map((line) => {
|
||||||
if (line.id !== lineId) return line;
|
if (line.id !== lineId) return line;
|
||||||
@@ -548,6 +642,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateFuriganaX = (lineId: string, itemId: string, x: number) => {
|
const handleUpdateFuriganaX = (lineId: string, itemId: string, x: number) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
setLines((prev) =>
|
setLines((prev) =>
|
||||||
prev.map((line) => {
|
prev.map((line) => {
|
||||||
if (line.id !== lineId) return line;
|
if (line.id !== lineId) return line;
|
||||||
@@ -571,6 +667,8 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleNewDocument = () => {
|
const handleNewDocument = () => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
|
|
||||||
if (!window.confirm('新建文档会清空当前纸面内容。请确认当前内容已经保存。')) return;
|
if (!window.confirm('新建文档会清空当前纸面内容。请确认当前内容已经保存。')) return;
|
||||||
|
|
||||||
const firstLine = createEmptyLine();
|
const firstLine = createEmptyLine();
|
||||||
@@ -616,6 +714,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkImport = () => {
|
const handleBulkImport = () => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
if (!importText.trim()) return;
|
if (!importText.trim()) return;
|
||||||
|
|
||||||
const importedLines = importText
|
const importedLines = importText
|
||||||
@@ -637,13 +736,13 @@ export default function App() {
|
|||||||
resetDocumentFocus();
|
resetDocumentFocus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDocumentBackup = () => {
|
const createDocumentBackup = (linesOverride = lines) => {
|
||||||
return {
|
return {
|
||||||
version: 'japanese-writing-pad-v5',
|
version: 'japanese-writing-pad-v5',
|
||||||
title: documentTitle.trim() || DEFAULT_DOCUMENT_TITLE,
|
title: documentTitle.trim() || DEFAULT_DOCUMENT_TITLE,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
config: { fontSize },
|
config: { fontSize },
|
||||||
lines: lines.map(removeEmptyFuriganaItems),
|
lines: linesOverride.map(removeEmptyFuriganaItems),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -672,13 +771,54 @@ export default function App() {
|
|||||||
|
|
||||||
const refreshServerSaveFiles = async () => {
|
const refreshServerSaveFiles = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saves');
|
const response = await fetch(apiUrl('/api/documents'));
|
||||||
if (!response.ok) throw new Error('Failed to list server saves');
|
if (!response.ok) throw new Error('Failed to list server saves');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setServerSaveFiles(Array.isArray(data.files) ? data.files : []);
|
const files: ServerSaveFile[] = Array.isArray(data.files) ? data.files : [];
|
||||||
|
setServerSaveFiles(files);
|
||||||
|
|
||||||
|
const savedNotes = await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
try {
|
||||||
|
const documentResponse = await fetch(
|
||||||
|
file.id
|
||||||
|
? apiUrl(`/api/documents/${encodeURIComponent(String(file.id))}`)
|
||||||
|
: apiUrl(`/api/saves/${encodeURIComponent(file.name)}`)
|
||||||
|
);
|
||||||
|
if (!documentResponse.ok) return [];
|
||||||
|
|
||||||
|
const documentData = await documentResponse.json();
|
||||||
|
const documentLines = normalizeLines(documentData?.lines, `saved-index-${file.name}`);
|
||||||
|
const title =
|
||||||
|
typeof documentData?.title === 'string' && documentData.title.trim()
|
||||||
|
? documentData.title
|
||||||
|
: file.title || file.name.replace(/\.json$/i, '');
|
||||||
|
|
||||||
|
return documentLines.flatMap((line, lineIndex) =>
|
||||||
|
line.notes.map((note): SavedDocumentNote => ({
|
||||||
|
id: note.id,
|
||||||
|
text: note.text,
|
||||||
|
comment: note.comment,
|
||||||
|
color: note.color,
|
||||||
|
lineId: line.id,
|
||||||
|
lineNumber: lineIndex + 1,
|
||||||
|
lineText: line.rawText,
|
||||||
|
documentTitle: title,
|
||||||
|
fileName: file.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error indexing saved document notes: ${file.name}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setSavedDocumentNotes(savedNotes.flat());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error refreshing server saves', e);
|
console.error('Error refreshing server saves', e);
|
||||||
setFileStatus('无法读取项目 saves 目录');
|
setFileStatus('无法读取项目 saves 目录');
|
||||||
|
setSavedDocumentNotes([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -696,14 +836,14 @@ export default function App() {
|
|||||||
}, 1600);
|
}, 1600);
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeDocumentToServerFile = async () => {
|
const writeDocumentToServerFile = async (linesOverride = lines) => {
|
||||||
const name = getCurrentDocumentSaveName();
|
const name = getCurrentDocumentSaveName();
|
||||||
const response = await fetch('/api/saves', {
|
const response = await fetch(apiUrl('/api/documents'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
document: createDocumentBackup(),
|
document: createDocumentBackup(linesOverride),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -718,6 +858,25 @@ export default function App() {
|
|||||||
await refreshServerSaveFiles();
|
await refreshServerSaveFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLinesWithPendingMobileNote = () => {
|
||||||
|
if (!currentSelection) return lines;
|
||||||
|
|
||||||
|
const textarea = document.getElementById(
|
||||||
|
mobileSelectedNote ? 'comment-textarea-mobile-existing' : 'comment-textarea-mobile'
|
||||||
|
) as HTMLTextAreaElement | null;
|
||||||
|
const comment = textarea?.value?.trim();
|
||||||
|
if (!comment) return lines;
|
||||||
|
|
||||||
|
return applyNoteToLines(
|
||||||
|
lines,
|
||||||
|
mobileSelectedNote?.lineId || currentSelection.lineId,
|
||||||
|
mobileSelectedNote?.text || currentSelection.text,
|
||||||
|
comment,
|
||||||
|
mobileSelectedColor,
|
||||||
|
mobileSelectedNote?.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveBoundLocalFile = async () => {
|
const handleSaveBoundLocalFile = async () => {
|
||||||
if (!documentTitle.trim()) {
|
if (!documentTitle.trim()) {
|
||||||
showTransientSaveNotice('保存失败', 'error');
|
showTransientSaveNotice('保存失败', 'error');
|
||||||
@@ -725,7 +884,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeDocumentToServerFile();
|
const nextLines = getLinesWithPendingMobileNote();
|
||||||
|
if (nextLines !== lines) {
|
||||||
|
setLines(nextLines);
|
||||||
|
}
|
||||||
|
await writeDocumentToServerFile(nextLines);
|
||||||
showTransientSaveNotice('已保存', 'success');
|
showTransientSaveNotice('已保存', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error saving project file', e);
|
console.error('Error saving project file', e);
|
||||||
@@ -738,11 +901,15 @@ export default function App() {
|
|||||||
setShowSavedArea((prev) => !prev);
|
setShowSavedArea((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadServerSave = async (fileName: string) => {
|
const handleLoadServerSave = async (file: ServerSaveFile) => {
|
||||||
try {
|
try {
|
||||||
if (!window.confirm(`加载 saves/${fileName}?当前未保存内容会被覆盖。`)) return;
|
if (!window.confirm(`打开「${file.title}」?当前未保存内容会被覆盖。`)) return;
|
||||||
|
|
||||||
const response = await fetch(`/api/saves/${encodeURIComponent(fileName)}`);
|
const response = await fetch(
|
||||||
|
file.id
|
||||||
|
? apiUrl(`/api/documents/${encodeURIComponent(String(file.id))}`)
|
||||||
|
: apiUrl(`/api/saves/${encodeURIComponent(file.name)}`)
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
alert('读取项目保存文件失败。');
|
alert('读取项目保存文件失败。');
|
||||||
return;
|
return;
|
||||||
@@ -754,13 +921,13 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBoundFileName(fileName);
|
setBoundFileName(file.name);
|
||||||
setDocumentTitle(
|
setDocumentTitle(
|
||||||
typeof data.title === 'string' && data.title.trim()
|
typeof data.title === 'string' && data.title.trim()
|
||||||
? data.title
|
? data.title
|
||||||
: fileName.replace(/\.json$/i, '')
|
: file.name.replace(/\.json$/i, '')
|
||||||
);
|
);
|
||||||
setFileStatus(`已打开:${typeof data.title === 'string' && data.title.trim() ? data.title : fileName}`);
|
setFileStatus(`已打开:${typeof data.title === 'string' && data.title.trim() ? data.title : file.name}`);
|
||||||
setShowSavedArea(false);
|
setShowSavedArea(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error loading project save', e);
|
console.error('Error loading project save', e);
|
||||||
@@ -772,7 +939,7 @@ export default function App() {
|
|||||||
if (!window.confirm(`删除项目保存文件 saves/${fileName}?该操作不可撤销。`)) return;
|
if (!window.confirm(`删除项目保存文件 saves/${fileName}?该操作不可撤销。`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/saves/${encodeURIComponent(fileName)}`, {
|
const response = await fetch(apiUrl(`/api/saves/${encodeURIComponent(fileName)}`), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to delete project save');
|
if (!response.ok) throw new Error('Failed to delete project save');
|
||||||
@@ -794,6 +961,20 @@ export default function App() {
|
|||||||
backgroundSize: 'none',
|
backgroundSize: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mobileSelectedNote = currentSelection
|
||||||
|
? lines
|
||||||
|
.flatMap((line) => line.notes.map((note) => ({ ...note, lineId: line.id })))
|
||||||
|
.find((note) =>
|
||||||
|
focusedNoteId
|
||||||
|
? note.id === focusedNoteId
|
||||||
|
: note.lineId === currentSelection.lineId && note.text === currentSelection.text
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileSelectedColor(mobileSelectedNote?.color || 'yellow');
|
||||||
|
}, [currentSelection?.text, currentSelection?.lineId, focusedNoteId, mobileSelectedNote?.id, mobileSelectedNote?.color]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-screen bg-[#FAF9F6] text-[#2D2926] overflow-hidden font-sans">
|
<div className="flex flex-col h-screen w-screen bg-[#FAF9F6] text-[#2D2926] overflow-hidden font-sans">
|
||||||
{saveNotice && (
|
{saveNotice && (
|
||||||
@@ -816,76 +997,50 @@ export default function App() {
|
|||||||
onViewSavedDocuments={handleOpenLocalFile}
|
onViewSavedDocuments={handleOpenLocalFile}
|
||||||
onSaveDocument={handleSaveBoundLocalFile}
|
onSaveDocument={handleSaveBoundLocalFile}
|
||||||
onToggleBulkImport={() => setShowImportArea((prev) => !prev)}
|
onToggleBulkImport={() => setShowImportArea((prev) => !prev)}
|
||||||
fileStatus={fileStatus}
|
|
||||||
showImportArea={showImportArea}
|
showImportArea={showImportArea}
|
||||||
showSavedArea={showSavedArea}
|
showSavedArea={showSavedArea}
|
||||||
savedDocumentCount={serverSaveFiles.length}
|
savedDocumentCount={serverSaveFiles.length}
|
||||||
|
canEditDocument={canEditDocument}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showSavedArea && (
|
<div className="flex flex-1 min-h-0 flex-col lg:flex-row">
|
||||||
<div className="fixed left-1/2 top-24 z-40 w-[calc(100vw-2rem)] max-w-3xl -translate-x-1/2 text-xs select-none">
|
<AppNav activeView={activeView} onChange={setActiveView} />
|
||||||
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-4 shadow-xl animate-in fade-in duration-200">
|
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-[#E8E4DE]/50 pb-3 mb-3">
|
{activeView === 'lyrics' && showSavedArea && (
|
||||||
<div>
|
<div className="fixed left-1/2 top-20 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
|
||||||
<div className="font-bold text-[#2D2926]">已保存文档</div>
|
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-2 shadow-xl animate-in fade-in duration-200">
|
||||||
<div className="mt-1 text-[11px] text-[#A69F92]">
|
|
||||||
文档保存在项目根目录的 <span className="font-mono">saves/</span> 目录中。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{serverSaveFiles.length > 0 ? (
|
{serverSaveFiles.length > 0 ? (
|
||||||
<div className="space-y-2 max-h-[min(60vh,420px)] overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[min(62vh,420px)] overflow-y-auto">
|
||||||
{serverSaveFiles.map((file) => (
|
{serverSaveFiles.map((file) => (
|
||||||
<div
|
<button
|
||||||
key={file.name}
|
key={file.name}
|
||||||
className="flex flex-wrap items-center justify-between gap-3 bg-white border border-[#E8E4DE]/70 rounded-lg px-3 py-2.5"
|
type="button"
|
||||||
|
onClick={() => handleLoadServerSave(file)}
|
||||||
|
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border border-[#E8E4DE]/70 hover:border-[#8B7E74]/40 rounded-lg px-3 py-2.5 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold text-[#2D2926] truncate">{file.title}</div>
|
<div className="font-semibold text-[#2D2926] truncate">{file.title}</div>
|
||||||
<div className="mt-1 text-[10px] text-[#A69F92] font-mono">
|
|
||||||
{file.name} · {new Date(file.savedAt).toLocaleString()} · {file.lineCount ?? '?'} 行
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => handleLoadServerSave(file.name)}
|
|
||||||
className="px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-semibold rounded-lg transition cursor-pointer"
|
|
||||||
>
|
|
||||||
打开
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteServerSave(file.name)}
|
|
||||||
className="px-3 py-1.5 bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 font-semibold rounded-lg transition cursor-pointer"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
|
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
|
||||||
还没有已保存文档。填写标题后点击顶部“保存”即可创建。
|
还没有保存的歌词
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 🔮 Outer Workspace Area */}
|
{activeView === 'lyrics' ? (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* LEFT COMPARTMENT: The writing sheet ledger */}
|
{/* LEFT COMPARTMENT: The writing sheet */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-4 md:px-4 flex flex-col items-center bg-[#FAF9F6]">
|
<div className="flex-1 overflow-y-auto px-1 py-1 md:px-4 md:py-4 flex flex-col items-center bg-[#FAF9F6]">
|
||||||
<div
|
<div
|
||||||
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] p-4 sm:p-6 md:p-8 relative flex flex-col animate-fadeIn overflow-x-auto"
|
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] px-1 py-2 sm:p-5 md:p-8 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
|
||||||
>
|
>
|
||||||
{/* Elegant watermark indicators (Minimal traditional look) */}
|
<div className="mt-1 mb-3 md:mt-2 md:mb-4 flex justify-center">
|
||||||
<div className="absolute top-4 right-6 text-[10px] text-[#A69F92]/70 pointer-events-none select-none font-mono flex items-center gap-1 z-10">
|
|
||||||
<BookOpen className="w-2.5 h-2.5 text-[#8B7E74]" /> JAPANESE WRITING LEDGER · 原稿用紙
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 mb-4 flex justify-center">
|
|
||||||
<input
|
<input
|
||||||
value={documentTitle}
|
value={documentTitle}
|
||||||
onChange={(e) => setDocumentTitle(e.target.value)}
|
onChange={(e) => setDocumentTitle(e.target.value)}
|
||||||
@@ -894,7 +1049,10 @@ export default function App() {
|
|||||||
setEditingLineId(null);
|
setEditingLineId(null);
|
||||||
}}
|
}}
|
||||||
placeholder="请输入文档标题"
|
placeholder="请输入文档标题"
|
||||||
className="w-full max-w-xl bg-transparent text-center font-serif text-2xl font-bold tracking-widest text-[#2D2926] placeholder-[#A69F92]/50 border-b border-[#E8E4DE] focus:border-[#8B7E74] focus:outline-none py-2"
|
disabled={!canEditDocument}
|
||||||
|
className={`w-full max-w-xl bg-transparent text-center font-serif text-xl sm:text-2xl font-bold tracking-widest text-[#2D2926] placeholder-[#A69F92]/50 border-b border-[#E8E4DE] focus:border-[#8B7E74] focus:outline-none py-1.5 sm:py-2 ${
|
||||||
|
canEditDocument ? '' : 'cursor-default border-transparent'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -961,7 +1119,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 📝 Integrated Book Page Layout (Double-click or click line to type/edit; select text or click highlights to view annotation) */}
|
{/* 📝 Integrated Book Page Layout (Double-click or click line to type/edit; select text or click highlights to view annotation) */}
|
||||||
<div className="flex-1 flex flex-col pt-2 select-text" onClick={(e) => {
|
<div className="flex-1 flex flex-col pt-1 md:pt-2 select-text" onClick={(e) => {
|
||||||
// If clicked the background paper itself, deactivate active editing line
|
// If clicked the background paper itself, deactivate active editing line
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
setActiveLineId(null);
|
setActiveLineId(null);
|
||||||
@@ -969,7 +1127,10 @@ export default function App() {
|
|||||||
setFocusedNoteId(null);
|
setFocusedNoteId(null);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn" style={paperBackgroundStyle}>
|
<div
|
||||||
|
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn rounded-xl border-x border-[#8B7E74]/10 bg-[#FDFCFB]/70 shadow-[inset_4px_0_10px_-10px_rgba(139,126,116,0.45),inset_-4px_0_10px_-10px_rgba(139,126,116,0.45)] md:border-x-0 md:bg-transparent md:shadow-none"
|
||||||
|
style={paperBackgroundStyle}
|
||||||
|
>
|
||||||
{lines.map((line, idx) => (
|
{lines.map((line, idx) => (
|
||||||
<LineItem
|
<LineItem
|
||||||
key={line.id}
|
key={line.id}
|
||||||
@@ -977,13 +1138,15 @@ export default function App() {
|
|||||||
lineNumber={idx + 1}
|
lineNumber={idx + 1}
|
||||||
showLineNumber={showLineNumbers}
|
showLineNumber={showLineNumbers}
|
||||||
isActive={activeLineId === line.id}
|
isActive={activeLineId === line.id}
|
||||||
readOnly={editingLineId !== line.id}
|
readOnly={!canEditDocument || editingLineId !== line.id}
|
||||||
|
canEdit={canEditDocument}
|
||||||
fontStyle={fontStyle}
|
fontStyle={fontStyle}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
lineSpacing={lineSpacing}
|
lineSpacing={lineSpacing}
|
||||||
onUpdate={handleUpdateLine}
|
onUpdate={handleUpdateLine}
|
||||||
onDelete={handleDeleteLine}
|
onDelete={handleDeleteLine}
|
||||||
onActivate={(lineId) => {
|
onActivate={(lineId) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
setActiveLineId(lineId);
|
setActiveLineId(lineId);
|
||||||
setFocusedNoteId(null);
|
setFocusedNoteId(null);
|
||||||
}}
|
}}
|
||||||
@@ -994,6 +1157,7 @@ export default function App() {
|
|||||||
setSelectedFurigana(null);
|
setSelectedFurigana(null);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(lineId) => {
|
onDoubleClick={(lineId) => {
|
||||||
|
if (!canEditDocument) return;
|
||||||
setActiveLineId(lineId);
|
setActiveLineId(lineId);
|
||||||
setEditingLineId(lineId);
|
setEditingLineId(lineId);
|
||||||
setFocusedNoteId(null);
|
setFocusedNoteId(null);
|
||||||
@@ -1033,10 +1197,10 @@ export default function App() {
|
|||||||
<div className="w-[300px] sm:w-[350px] md:w-[380px] h-full flex-shrink-0 hidden lg:block border-l border-[#E8E4DE]/60">
|
<div className="w-[300px] sm:w-[350px] md:w-[380px] h-full flex-shrink-0 hidden lg:block border-l border-[#E8E4DE]/60">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
lines={lines}
|
lines={lines}
|
||||||
activeLineId={activeLineId}
|
|
||||||
currentSelection={currentSelection}
|
currentSelection={currentSelection}
|
||||||
selectedFurigana={selectedFurigana}
|
selectedFurigana={selectedFurigana}
|
||||||
focusedNoteId={focusedNoteId}
|
focusedNoteId={focusedNoteId}
|
||||||
|
savedDocumentNotes={savedDocumentNotes.filter((note) => note.fileName !== boundFileName)}
|
||||||
onFocusNote={handleFocusNote}
|
onFocusNote={handleFocusNote}
|
||||||
onAddNote={handleAddNote}
|
onAddNote={handleAddNote}
|
||||||
onDeleteNote={handleDeleteNote}
|
onDeleteNote={handleDeleteNote}
|
||||||
@@ -1052,44 +1216,150 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<VocabCards />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Floating Sticky Drawer for Mobile / Tablet screens which normally hides the Sidebar */}
|
{/* Floating Sticky Drawer for Mobile / Tablet screens which normally hides the Sidebar */}
|
||||||
{currentSelection && (
|
{activeView === 'lyrics' && currentSelection && (
|
||||||
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col p-4 overflow-y-auto animate-in slide-in-from-bottom">
|
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<span className="text-xs font-bold text-[#2D2926] bg-[#8B7E74]/10 px-2.5 py-1 rounded-md border border-[#8B7E74]/20">
|
|
||||||
划词注释 (移动端视图)
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentSelection(null)}
|
|
||||||
className="text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[#A69F92] mb-2 font-sans">
|
|
||||||
选中:“<span className="font-serif font-bold text-[#2D2926] text-sm">{currentSelection.text}</span>”
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
id="comment-textarea-mobile"
|
|
||||||
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
|
|
||||||
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-3 text-[#2D2926]"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const el = document.getElementById('comment-textarea-mobile') as HTMLTextAreaElement;
|
window.getSelection()?.removeAllRanges();
|
||||||
const val = el?.value?.trim() || '';
|
setCurrentSelection(null);
|
||||||
if (val) {
|
setFocusedNoteId(null);
|
||||||
handleAddNote(currentSelection.lineId, currentSelection.text, val, 'yellow');
|
|
||||||
setCurrentSelection(null);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
|
||||||
>
|
>
|
||||||
快速保存
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-1.5 pr-8 font-sans flex items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1 text-xs text-[#A69F92]">
|
||||||
|
选中:“<span className="font-serif font-bold text-[#2D2926] text-sm">{currentSelection.text}</span>”
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentSelection.text);
|
||||||
|
showTransientSaveNotice('已复制', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying selected text', error);
|
||||||
|
showTransientSaveNotice('复制失败', 'error');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 flex items-center gap-1 rounded-md border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
|
||||||
|
title="复制选中文本"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={mobileDrawerInteractive ? '' : 'pointer-events-none'}>
|
||||||
|
{mobileSelectedNote ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[11px] font-bold text-[#8B7E74]">已有注释,可直接修改</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold text-[#A69F92]">颜色</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{MOBILE_HIGHLIGHT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileSelectedColor(color.value)}
|
||||||
|
title={color.label}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 ${color.bg} flex items-center justify-center transition ${
|
||||||
|
mobileSelectedColor === color.value
|
||||||
|
? 'border-[#8B7E74] scale-110'
|
||||||
|
: 'border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mobileSelectedColor === color.value && (
|
||||||
|
<Check className={`w-3.5 h-3.5 ${color.check} stroke-[3]`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
id="comment-textarea-mobile-existing"
|
||||||
|
defaultValue={mobileSelectedNote.comment}
|
||||||
|
className="w-full text-sm bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const el = document.getElementById('comment-textarea-mobile-existing') as HTMLTextAreaElement;
|
||||||
|
const val = el?.value?.trim() || '';
|
||||||
|
if (val) {
|
||||||
|
handleAddNote(
|
||||||
|
mobileSelectedNote.lineId,
|
||||||
|
mobileSelectedNote.text,
|
||||||
|
val,
|
||||||
|
mobileSelectedColor,
|
||||||
|
mobileSelectedNote.id
|
||||||
|
);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setCurrentSelection(null);
|
||||||
|
setFocusedNoteId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||||
|
>
|
||||||
|
保存修改
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold text-[#A69F92]">颜色</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{MOBILE_HIGHLIGHT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileSelectedColor(color.value)}
|
||||||
|
title={color.label}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 ${color.bg} flex items-center justify-center transition ${
|
||||||
|
mobileSelectedColor === color.value
|
||||||
|
? 'border-[#8B7E74] scale-110'
|
||||||
|
: 'border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mobileSelectedColor === color.value && (
|
||||||
|
<Check className={`w-3.5 h-3.5 ${color.check} stroke-[3]`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
id="comment-textarea-mobile"
|
||||||
|
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
|
||||||
|
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const el = document.getElementById('comment-textarea-mobile') as HTMLTextAreaElement;
|
||||||
|
const val = el?.value?.trim() || '';
|
||||||
|
if (val) {
|
||||||
|
handleAddNote(currentSelection.lineId, currentSelection.text, val, mobileSelectedColor);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setCurrentSelection(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
|
||||||
|
>
|
||||||
|
快速保存
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.replace(/\/+$/, '') || '';
|
||||||
|
|
||||||
|
export const apiUrl = (path: string) => {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${API_BASE_URL}${normalizedPath}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { BookOpen, Layers3 } from 'lucide-react';
|
||||||
|
|
||||||
|
export type AppView = 'lyrics' | 'vocab';
|
||||||
|
|
||||||
|
interface AppNavProps {
|
||||||
|
activeView: AppView;
|
||||||
|
onChange: (view: AppView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'lyrics' as const, label: '歌词', icon: BookOpen },
|
||||||
|
{ id: 'vocab' as const, label: '单词卡片', icon: Layers3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-2 border-r border-[#E8E4DE] bg-[#FDFCFB] p-2 shadow-[8px_0_24px_-24px_rgba(45,41,38,0.45)]">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeView === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
className={`flex w-full flex-col items-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8B7E74] text-white'
|
||||||
|
: 'bg-white text-[#8B7E74] hover:bg-[#FAF9F6] hover:text-[#2D2926]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav className="order-last grid shrink-0 grid-cols-2 border-t border-[#E8E4DE] bg-[#FDFCFB] px-3 py-2 shadow-[0_-8px_24px_-18px_rgba(45,41,38,0.4)] lg:hidden">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeView === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
className={`mx-1 flex items-center justify-center gap-2 rounded-lg py-2 text-xs font-semibold transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8B7E74] text-white'
|
||||||
|
: 'bg-white text-[#8B7E74] border border-[#E8E4DE]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ interface LineItemProps {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
lineSpacing: number;
|
lineSpacing: number;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void;
|
onUpdate: (lineId: string, newRawText: string, newFuriganaItems?: FuriganaItem[]) => void;
|
||||||
onDelete: (lineId: string) => void;
|
onDelete: (lineId: string) => void;
|
||||||
onActivate: (lineId: string) => void;
|
onActivate: (lineId: string) => void;
|
||||||
@@ -41,6 +42,7 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
fontSize,
|
fontSize,
|
||||||
lineSpacing,
|
lineSpacing,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
canEdit,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
onActivate,
|
onActivate,
|
||||||
@@ -58,9 +60,25 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
const furiganaLayerRef = useRef<FuriganaLayerHandle>(null);
|
const furiganaLayerRef = useRef<FuriganaLayerHandle>(null);
|
||||||
const mainInputRef = useRef<HTMLInputElement>(null);
|
const mainInputRef = useRef<HTMLInputElement>(null);
|
||||||
const displayRef = useRef<HTMLDivElement>(null);
|
const displayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mainTextRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileSelectionRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
selecting: boolean;
|
||||||
|
startPoint: TextHitPoint | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const furiganaItems = line.furiganaItems;
|
const furiganaItems = line.furiganaItems;
|
||||||
|
|
||||||
|
type TextHitPoint = {
|
||||||
|
node: Text;
|
||||||
|
offset: number;
|
||||||
|
globalOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
// Expose focus methods dynamically
|
// Expose focus methods dynamically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCustomFocus = (e: CustomEvent<{ field: 'main' | 'furigana' }>) => {
|
const handleCustomFocus = (e: CustomEvent<{ field: 'main' | 'furigana' }>) => {
|
||||||
@@ -121,23 +139,152 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
onUpdate(line.id, line.rawText, nextItems);
|
onUpdate(line.id, line.rawText, nextItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCaretRangeFromPoint = (clientX: number, clientY: number) => {
|
||||||
|
const doc = document as Document & {
|
||||||
|
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||||
|
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (doc.caretRangeFromPoint) {
|
||||||
|
return doc.caretRangeFromPoint(clientX, clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = doc.caretPositionFromPoint?.(clientX, clientY);
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(position.offsetNode, position.offset);
|
||||||
|
range.collapse(true);
|
||||||
|
return range;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextHitPoint = (clientX: number, clientY: number): TextHitPoint | null => {
|
||||||
|
const root = mainTextRef.current;
|
||||||
|
const range = getCaretRangeFromPoint(clientX, clientY);
|
||||||
|
if (!root || !range || !root.contains(range.startContainer)) return null;
|
||||||
|
|
||||||
|
if (range.startContainer.nodeType !== Node.TEXT_NODE) return null;
|
||||||
|
|
||||||
|
const targetNode = range.startContainer as Text;
|
||||||
|
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||||
|
let globalOffset = 0;
|
||||||
|
let current = treeWalker.nextNode() as Text | null;
|
||||||
|
|
||||||
|
while (current) {
|
||||||
|
if (current === targetNode) {
|
||||||
|
return {
|
||||||
|
node: targetNode,
|
||||||
|
offset: Math.min(range.startOffset, targetNode.data.length),
|
||||||
|
globalOffset: globalOffset + Math.min(range.startOffset, targetNode.data.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
globalOffset += current.data.length;
|
||||||
|
current = treeWalker.nextNode() as Text | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisualSelection = (startPoint: TextHitPoint, endPoint: TextHitPoint) => {
|
||||||
|
const range = document.createRange();
|
||||||
|
if (startPoint.globalOffset <= endPoint.globalOffset) {
|
||||||
|
range.setStart(startPoint.node, startPoint.offset);
|
||||||
|
range.setEnd(endPoint.node, endPoint.offset);
|
||||||
|
} else {
|
||||||
|
range.setStart(endPoint.node, endPoint.offset);
|
||||||
|
range.setEnd(startPoint.node, startPoint.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
return selection?.toString().trim() || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileSelectionStart = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (canEdit || e.pointerType !== 'touch' || !line.rawText) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-note-highlight="true"]') || target.closest('[data-furigana-layer="true"]')) return;
|
||||||
|
|
||||||
|
const startPoint = getTextHitPoint(e.clientX, e.clientY);
|
||||||
|
mobileSelectionRef.current = {
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
currentX: e.clientX,
|
||||||
|
currentY: e.clientY,
|
||||||
|
selecting: false,
|
||||||
|
startPoint,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileSelectionMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const gesture = mobileSelectionRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
||||||
|
|
||||||
|
gesture.currentX = e.clientX;
|
||||||
|
gesture.currentY = e.clientY;
|
||||||
|
const dx = Math.abs(e.clientX - gesture.startX);
|
||||||
|
const dy = Math.abs(e.clientY - gesture.startY);
|
||||||
|
|
||||||
|
if (!gesture.selecting && dx > 10 && dx > dy * 1.15) {
|
||||||
|
gesture.selecting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesture.selecting) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const endPoint = getTextHitPoint(e.clientX, e.clientY);
|
||||||
|
if (gesture.startPoint && endPoint) {
|
||||||
|
setVisualSelection(gesture.startPoint, endPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileSelectionEnd = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const gesture = mobileSelectionRef.current;
|
||||||
|
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
|
||||||
|
if (!gesture.selecting) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const endPoint = getTextHitPoint(gesture.currentX, gesture.currentY);
|
||||||
|
const selectedText =
|
||||||
|
gesture.startPoint && endPoint
|
||||||
|
? setVisualSelection(gesture.startPoint, endPoint)
|
||||||
|
: window.getSelection()?.toString().trim() || '';
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
onTextSelect(selectedText, line.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Capture selection in reading mode to add annotations
|
// Capture selection in reading mode to add annotations
|
||||||
const handleSelection = () => {
|
const handleSelection = () => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.isCollapsed) return;
|
if (!selection || selection.isCollapsed) return;
|
||||||
|
|
||||||
|
const isNodeInsideFurigana = (node: Node | null) => {
|
||||||
|
const element = node instanceof Element ? node : node?.parentElement;
|
||||||
|
return !!element?.closest('[data-furigana-layer="true"]');
|
||||||
|
};
|
||||||
|
|
||||||
const selectedText = selection.toString().trim();
|
const selectedText = selection.toString().trim();
|
||||||
if (selectedText.length > 0) {
|
if (selectedText.length > 0) {
|
||||||
const lowerSelected = selectedText.toLowerCase();
|
const lowerSelected = selectedText.toLowerCase();
|
||||||
const mainTextLower = (line.rawText || '').toLowerCase();
|
const mainTextLower = (line.rawText || '').toLowerCase();
|
||||||
const furiTextLower = furiganaItems.map((item) => item.text).join(' ').toLowerCase();
|
|
||||||
|
|
||||||
const isInside = displayRef.current && (
|
const isInside = displayRef.current && (
|
||||||
displayRef.current.contains(selection.anchorNode) ||
|
displayRef.current.contains(selection.anchorNode) ||
|
||||||
displayRef.current.contains(selection.focusNode)
|
displayRef.current.contains(selection.focusNode)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isInside || mainTextLower.includes(lowerSelected) || furiTextLower.includes(lowerSelected)) {
|
const isInsideFurigana = isNodeInsideFurigana(selection.anchorNode) || isNodeInsideFurigana(selection.focusNode);
|
||||||
|
|
||||||
|
if (!isInsideFurigana && (isInside || mainTextLower.includes(lowerSelected))) {
|
||||||
onTextSelect(selectedText, line.id);
|
onTextSelect(selectedText, line.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +340,12 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
<span
|
<span
|
||||||
key={`highlight-${earliestNote.id}-${earliestIndex}`}
|
key={`highlight-${earliestNote.id}-${earliestIndex}`}
|
||||||
data-note-highlight="true"
|
data-note-highlight="true"
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
if (e.pointerType !== 'touch') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onNoteClick(earliestNote!);
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onNoteClick(earliestNote!);
|
onNoteClick(earliestNote!);
|
||||||
@@ -220,23 +373,23 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`line-${line.id}`}
|
id={`line-${line.id}`}
|
||||||
className={`relative group flex gap-4 pr-3 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 min-w-[800px] md:min-w-[1200px] xl:min-w-[1600px]`}
|
className={`relative group flex w-full min-w-0 gap-1 pr-1 transition-colors ${isActive ? 'bg-[#FAF9F6]/40' : ''} hover:bg-[#FAF9F6]/40 md:min-w-[900px] md:gap-4 md:pr-3 xl:min-w-[1200px]`}
|
||||||
style={{ minHeight: `${calculatedLineHeight}px` }}
|
style={{ minHeight: `${calculatedLineHeight}px` }}
|
||||||
>
|
>
|
||||||
{/* 1. Left gutter with number */}
|
{/* 1. Left gutter with number */}
|
||||||
<div className="relative flex flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 w-12 border-r border-[#E8E4DE]/50 font-mono">
|
{showLineNumber && (
|
||||||
{showLineNumber && (
|
<div className="relative flex w-8 shrink-0 flex-col items-center justify-start py-1 select-none text-[#A69F92]/60 border-r border-[#E8E4DE]/50 font-mono sm:w-10 md:w-12">
|
||||||
<span className="text-[11px] font-bold select-none opacity-60">
|
<span className="text-[11px] font-bold select-none opacity-60">
|
||||||
{lineNumber.toString().padStart(2, '0')}
|
{lineNumber.toString().padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 2. Interactive sheet writing lines (Direct live editing) */}
|
{/* 2. Interactive sheet writing lines (Direct live editing) */}
|
||||||
<div className="flex-1 py-1 flex flex-col justify-center">
|
<div className="min-w-0 flex-1 py-1 flex flex-col justify-center">
|
||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
|
/* ================= EDIT MODE: FREE-POSITIONED FURIGANA + MAIN TEXT ================= */
|
||||||
<div className="flex flex-col w-full px-1 -mx-1 pr-1 font-mono">
|
<div className="flex flex-col w-full min-w-0 overflow-hidden px-0.5 pr-0.5 font-mono md:-mx-1 md:px-1 md:pr-1 md:overflow-visible">
|
||||||
{/* Top Row: Free-positioned Furigana / 假名 layer */}
|
{/* Top Row: Free-positioned Furigana / 假名 layer */}
|
||||||
<FuriganaLayer
|
<FuriganaLayer
|
||||||
ref={furiganaLayerRef}
|
ref={furiganaLayerRef}
|
||||||
@@ -247,7 +400,6 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||||
onChange={updateFuriganaItems}
|
onChange={updateFuriganaItems}
|
||||||
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
||||||
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
|
||||||
onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })}
|
onFocusMain={() => mainInputRef.current?.focus({ preventScroll: true })}
|
||||||
onFocusPreviousLine={() => moveFocusUp(line.id, 'main')}
|
onFocusPreviousLine={() => moveFocusUp(line.id, 'main')}
|
||||||
/>
|
/>
|
||||||
@@ -286,40 +438,50 @@ export const LineItem: React.FC<LineItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={displayRef}
|
ref={displayRef}
|
||||||
onMouseUp={handleSelection}
|
onMouseUp={handleSelection}
|
||||||
|
onPointerDown={handleMobileSelectionStart}
|
||||||
|
onPointerMove={handleMobileSelectionMove}
|
||||||
|
onPointerUp={handleMobileSelectionEnd}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
mobileSelectionRef.current = null;
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return;
|
if ((e.target as HTMLElement).closest('[data-furigana-layer="true"]')) return;
|
||||||
|
if (!canEdit) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection && !selection.isCollapsed) return;
|
if (selection && !selection.isCollapsed) return;
|
||||||
onActivate(line.id);
|
onActivate(line.id);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (onDoubleClick) {
|
if (canEdit && onDoubleClick) {
|
||||||
onDoubleClick(line.id);
|
onDoubleClick(line.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex flex-col w-full cursor-text select-text pr-1 font-mono hover:bg-[#FAF9F6]/80 rounded px-1 -mx-1"
|
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 rounded px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
|
||||||
|
canEdit ? 'cursor-text' : 'cursor-default'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* Furigana line */}
|
{/* Furigana line */}
|
||||||
<FuriganaLayer
|
<FuriganaLayer
|
||||||
items={furiganaItems}
|
items={furiganaItems}
|
||||||
editable
|
editable={canEdit}
|
||||||
selectedItemId={selectedFuriganaItemId}
|
selectedItemId={selectedFuriganaItemId}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
fontFamily={isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace'}
|
||||||
onChange={updateFuriganaItems}
|
onChange={updateFuriganaItems}
|
||||||
onItemSelect={(itemId) => onFuriganaSelect(line.id, itemId)}
|
onItemSelect={canEdit ? (itemId) => onFuriganaSelect(line.id, itemId) : undefined}
|
||||||
onTextSelect={(selectedText) => onTextSelect(selectedText, line.id)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Kanji text line with word highlighter/comment select matches */}
|
{/* Main Kanji text line with word highlighter/comment select matches */}
|
||||||
<div
|
<div
|
||||||
className="text-[#2D2926] m-0 p-0"
|
ref={mainTextRef}
|
||||||
|
className="text-[#2D2926] m-0 p-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||||
style={{
|
style={{
|
||||||
fontSize: `${fontSize}px`,
|
fontSize: `${fontSize}px`,
|
||||||
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
fontFamily: isSerif ? 'var(--font-serif), monospace' : 'var(--font-sans), monospace',
|
||||||
marginTop: '1px',
|
marginTop: '1px',
|
||||||
height: `${fontSize * 1.2}px`,
|
minHeight: `${fontSize * 1.2}px`,
|
||||||
lineHeight: '1.2',
|
lineHeight: '1.2',
|
||||||
|
touchAction: canEdit ? 'auto' : 'pan-y',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{line.rawText ? (
|
{line.rawText ? (
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { SidebarNote, WritingLine } from '../types';
|
import { SidebarNote, WritingLine } from '../types';
|
||||||
import { BookOpen, Tag, Trash2, Search, FileText, ChevronRight, HelpCircle, X, Check } from 'lucide-react';
|
import { BookOpen, Tag, Trash2, FileText, ChevronRight, HelpCircle, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
lines: WritingLine[];
|
lines: WritingLine[];
|
||||||
activeLineId: string | null;
|
|
||||||
currentSelection: { text: string; lineId: string } | null;
|
currentSelection: { text: string; lineId: string } | null;
|
||||||
selectedFurigana: { lineId: string; itemId: string } | null;
|
selectedFurigana: { lineId: string; itemId: string } | null;
|
||||||
focusedNoteId: string | null;
|
focusedNoteId: string | null;
|
||||||
|
savedDocumentNotes: SavedDocumentNote[];
|
||||||
onFocusNote: (noteId: string | null) => void;
|
onFocusNote: (noteId: string | null) => void;
|
||||||
onAddNote: (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => void;
|
onAddNote: (lineId: string, noteText: string, comment: string, color: string, noteId?: string) => void;
|
||||||
onDeleteNote: (lineId: string, noteId: string) => void;
|
onDeleteNote: (lineId: string, noteId: string) => void;
|
||||||
@@ -23,6 +23,18 @@ interface SidebarProps {
|
|||||||
onJumpToLine: (lineId: string) => void;
|
onJumpToLine: (lineId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedDocumentNote {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
comment: string;
|
||||||
|
color: string;
|
||||||
|
lineId: string;
|
||||||
|
lineNumber: number;
|
||||||
|
lineText: string;
|
||||||
|
documentTitle: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
const HIGHLIGHT_COLORS = [
|
const HIGHLIGHT_COLORS = [
|
||||||
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', border: 'border-[#8B7E74]', check: 'text-[#2D2926]' },
|
{ value: 'yellow', label: '岩砂褐', bg: 'bg-[#E8E4DE]', border: 'border-[#8B7E74]', check: 'text-[#2D2926]' },
|
||||||
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', border: 'border-rose-400', check: 'text-rose-700' },
|
{ value: 'rose', label: '绯樱红', bg: 'bg-rose-100', border: 'border-rose-400', check: 'text-rose-700' },
|
||||||
@@ -33,10 +45,10 @@ const HIGHLIGHT_COLORS = [
|
|||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
lines,
|
lines,
|
||||||
activeLineId,
|
|
||||||
currentSelection,
|
currentSelection,
|
||||||
selectedFurigana,
|
selectedFurigana,
|
||||||
focusedNoteId,
|
focusedNoteId,
|
||||||
|
savedDocumentNotes,
|
||||||
onFocusNote,
|
onFocusNote,
|
||||||
onAddNote,
|
onAddNote,
|
||||||
onDeleteNote,
|
onDeleteNote,
|
||||||
@@ -48,8 +60,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [selectedColor, setSelectedColor] = useState('yellow');
|
const [selectedColor, setSelectedColor] = useState('yellow');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [filterMode, setFilterMode] = useState<'all' | 'active'>('all');
|
|
||||||
|
|
||||||
// Check if we are editing an existing note
|
// Check if we are editing an existing note
|
||||||
const isEditing = !!(
|
const isEditing = !!(
|
||||||
@@ -81,6 +91,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
const selectedFuriganaMaxX = selectedFuriganaLine
|
const selectedFuriganaMaxX = selectedFuriganaLine
|
||||||
? Math.max(4, [...selectedFuriganaLine.rawText].length + 4, (selectedFuriganaItem?.x || 0) + 2)
|
? Math.max(4, [...selectedFuriganaLine.rawText].length + 4, (selectedFuriganaItem?.x || 0) + 2)
|
||||||
: 4;
|
: 4;
|
||||||
|
const isFuriganaMode = !!(selectedFurigana && selectedFuriganaLine && selectedFuriganaItem);
|
||||||
|
|
||||||
// Automatically update the comment input field when the selected text/note changes
|
// Automatically update the comment input field when the selected text/note changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -127,41 +138,57 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compile all notes across the writing pad
|
// Compile all notes across the document
|
||||||
const allNotesWithLineContext = lines.flatMap((line, idx) => {
|
const allNotesWithLineContext = lines.flatMap((line, idx) => {
|
||||||
return line.notes.map((note) => ({
|
return line.notes.map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
lineId: line.id,
|
lineId: line.id,
|
||||||
lineNumber: idx + 1,
|
lineNumber: idx + 1,
|
||||||
|
lineText: line.rawText,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter notes based on search query, current line focus, and focusedNoteId
|
const selectedTextForMatch = currentSelection?.text.trim() || '';
|
||||||
const filteredNotes = allNotesWithLineContext.filter((note) => {
|
const exactMatchedNotes = selectedTextForMatch
|
||||||
if (focusedNoteId) {
|
? [
|
||||||
return note.id === focusedNoteId;
|
...allNotesWithLineContext
|
||||||
}
|
.filter((note) => note.lineId !== currentSelection?.lineId)
|
||||||
|
.map((note) => ({
|
||||||
|
...note,
|
||||||
|
documentTitle: '当前歌词',
|
||||||
|
fileName: '',
|
||||||
|
source: 'current' as const,
|
||||||
|
})),
|
||||||
|
...savedDocumentNotes.map((note) => ({
|
||||||
|
...note,
|
||||||
|
source: 'saved' as const,
|
||||||
|
})),
|
||||||
|
].filter((note) => note.text.trim() === selectedTextForMatch)
|
||||||
|
: [];
|
||||||
|
|
||||||
const matchesSearch =
|
const filteredNotes = focusedNoteId
|
||||||
note.text.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
? allNotesWithLineContext.filter((note) => note.id === focusedNoteId)
|
||||||
note.comment.toLowerCase().includes(searchQuery.toLowerCase());
|
: allNotesWithLineContext;
|
||||||
|
|
||||||
if (filterMode === 'active') {
|
|
||||||
return matchesSearch && note.lineId === activeLineId;
|
|
||||||
}
|
|
||||||
return matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-[#FDFCFB] border-l border-[#E8E4DE]" id="annotations-sidebar">
|
<div className="flex flex-col h-full bg-[#FDFCFB] border-l border-[#E8E4DE]" id="annotations-sidebar">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/80 flex items-center justify-between">
|
<div className="p-4 border-b border-[#E8E4DE] bg-[#FAF9F6]/80 flex items-center justify-between">
|
||||||
<h2 className="font-serif font-bold text-[#2D2926] text-sm flex items-center gap-2">
|
<h2 className="font-serif font-bold text-[#2D2926] text-sm flex items-center gap-2">
|
||||||
<BookOpen className="w-4 h-4 text-[#8B7E74]" />
|
{isFuriganaMode ? (
|
||||||
<span>释义与随堂笔录</span>
|
<>
|
||||||
<span className="font-mono text-xs text-[#A69F92] font-normal">
|
<Tag className="w-4 h-4 text-[#8B7E74]" />
|
||||||
({allNotesWithLineContext.length})
|
<span>假名操作</span>
|
||||||
</span>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BookOpen className="w-4 h-4 text-[#8B7E74]" />
|
||||||
|
<span>释义与随堂笔录</span>
|
||||||
|
<span className="font-mono text-xs text-[#A69F92] font-normal">
|
||||||
|
({allNotesWithLineContext.length})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -356,154 +383,146 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FILTER & SEARCH OVERVIEW */}
|
{!isFuriganaMode && (
|
||||||
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE] flex flex-col gap-2">
|
<>
|
||||||
{/* Toggle line vs document */}
|
{exactMatchedNotes.length > 0 && (
|
||||||
<div className="flex bg-[#E8E4DE]/60 rounded p-0.5 text-xs font-sans">
|
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE]">
|
||||||
<button
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 shadow-xs">
|
||||||
onClick={() => setFilterMode('all')}
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
|
<span className="text-sm text-amber-900 font-bold font-sans tracking-wide">
|
||||||
filterMode === 'all'
|
其他歌词中出现
|
||||||
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
|
|
||||||
: 'text-[#A69F92] hover:bg-white/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全篇注释
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterMode('active')}
|
|
||||||
className={`flex-1 py-1 rounded text-center transition-all cursor-pointer ${
|
|
||||||
filterMode === 'active'
|
|
||||||
? 'bg-white text-[#2D2926] font-semibold border border-[#E8E4DE]/40 shadow-xs'
|
|
||||||
: 'text-[#A69F92] hover:bg-white/30 font-medium'
|
|
||||||
}`}
|
|
||||||
disabled={!activeLineId}
|
|
||||||
title={!activeLineId ? '请先在写作板中双击或选中一行' : ''}
|
|
||||||
>
|
|
||||||
仅看当前行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search bar */}
|
|
||||||
<div className="relative font-sans">
|
|
||||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-[#A69F92]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索已有注释..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full text-xs bg-[#FAF9F6] border border-[#E8E4DE] text-[#2D2926] pl-8 pr-3 py-1.5 rounded focus:outline-none focus:bg-white focus:ring-1 focus:ring-[#8B7E74]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{focusedNoteId && (
|
|
||||||
<div className="mx-3 mt-3 px-3 py-2 bg-[#8B7E74]/10 rounded-md border border-[#8B7E74]/20 flex items-center justify-between text-xs text-[#2D2926] font-medium font-sans animate-fadeIn">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="w-1.5 h-1.5 bg-[#8B7E74] rounded-full animate-pulse"></span>
|
|
||||||
已聚焦单条注释
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => onFocusNote(null)}
|
|
||||||
className="text-[#8B7E74] hover:text-[#2D2926] font-semibold hover:underline flex items-center gap-0.5 cursor-pointer"
|
|
||||||
>
|
|
||||||
显示全部注释
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* LIST OF PREVIOUS NOTES */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-3.5 select-none bg-[#FAF9F6]/20">
|
|
||||||
{filteredNotes.length > 0 ? (
|
|
||||||
filteredNotes.map((note) => {
|
|
||||||
let badgeColorClass = 'bg-[#FAF9F6] border-[#E8E4DE] text-[#2D2926]';
|
|
||||||
let dotColor = 'bg-[#8B7E74]';
|
|
||||||
if (note.color === 'rose') {
|
|
||||||
badgeColorClass = 'bg-rose-50 border-rose-200 text-rose-950';
|
|
||||||
dotColor = 'bg-rose-500';
|
|
||||||
} else if (note.color === 'sky') {
|
|
||||||
badgeColorClass = 'bg-sky-50 border-sky-200 text-sky-950';
|
|
||||||
dotColor = 'bg-sky-500';
|
|
||||||
} else if (note.color === 'emerald') {
|
|
||||||
badgeColorClass = 'bg-emerald-50 border-emerald-200 text-emerald-950';
|
|
||||||
dotColor = 'bg-emerald-500';
|
|
||||||
} else if (note.color === 'violet') {
|
|
||||||
badgeColorClass = 'bg-purple-50 border-purple-200 text-purple-950';
|
|
||||||
dotColor = 'bg-purple-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFocused = focusedNoteId === note.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (isFocused) {
|
|
||||||
onFocusNote(null);
|
|
||||||
} else {
|
|
||||||
onFocusNote(note.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
|
|
||||||
isFocused ? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30' : 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Note metadata */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Highlight text indicator */}
|
|
||||||
<span
|
|
||||||
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
|
|
||||||
title={note.text}
|
|
||||||
>
|
|
||||||
{note.text}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-amber-700 font-mono font-semibold">
|
||||||
|
{exactMatchedNotes.length} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Jump-to Line indicator */}
|
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
||||||
<button
|
{exactMatchedNotes.map((note) => (
|
||||||
onClick={(e) => {
|
<div
|
||||||
e.stopPropagation();
|
key={`${note.lineId}-${note.id}`}
|
||||||
onJumpToLine(note.lineId);
|
className="rounded border border-amber-200 bg-white p-2.5 shadow-xs"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
{note.source === 'current' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJumpToLine(note.lineId)}
|
||||||
|
className="text-xs text-amber-800 hover:text-[#2D2926] font-mono font-bold cursor-pointer"
|
||||||
|
>
|
||||||
|
当前歌词 · 行 {note.lineNumber.toString().padStart(2, '0')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-amber-800 font-mono font-bold">
|
||||||
|
{note.documentTitle} · 行 {note.lineNumber.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-amber-700 font-serif truncate" title={note.lineText}>
|
||||||
|
{note.lineText}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base leading-relaxed text-[#2D2926] break-words">
|
||||||
|
{note.comment}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!focusedNoteId && (
|
||||||
|
/* LIST OF PREVIOUS NOTES */
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-3.5 select-none bg-[#FAF9F6]/20">
|
||||||
|
{filteredNotes.length > 0 ? (
|
||||||
|
filteredNotes.map((note) => {
|
||||||
|
let badgeColorClass = 'bg-[#FAF9F6] border-[#E8E4DE] text-[#2D2926]';
|
||||||
|
let dotColor = 'bg-[#8B7E74]';
|
||||||
|
if (note.color === 'rose') {
|
||||||
|
badgeColorClass = 'bg-rose-50 border-rose-200 text-rose-950';
|
||||||
|
dotColor = 'bg-rose-500';
|
||||||
|
} else if (note.color === 'sky') {
|
||||||
|
badgeColorClass = 'bg-sky-50 border-sky-200 text-sky-950';
|
||||||
|
dotColor = 'bg-sky-500';
|
||||||
|
} else if (note.color === 'emerald') {
|
||||||
|
badgeColorClass = 'bg-emerald-50 border-emerald-200 text-emerald-950';
|
||||||
|
dotColor = 'bg-emerald-500';
|
||||||
|
} else if (note.color === 'violet') {
|
||||||
|
badgeColorClass = 'bg-purple-50 border-purple-200 text-purple-950';
|
||||||
|
dotColor = 'bg-purple-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFocused = focusedNoteId === note.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (isFocused) {
|
||||||
|
onFocusNote(null);
|
||||||
|
} else {
|
||||||
|
onFocusNote(note.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
|
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
|
||||||
title="点击跳转并定位此行"
|
isFocused
|
||||||
|
? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30'
|
||||||
|
: 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
行 {note.lineNumber.toString().padStart(2, '0')}
|
{/* Note metadata */}
|
||||||
<ChevronRight className="w-2.5 h-2.5" />
|
<div className="flex items-center justify-between">
|
||||||
</button>
|
{/* Highlight text indicator */}
|
||||||
</div>
|
<span
|
||||||
|
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
|
||||||
|
title={note.text}
|
||||||
|
>
|
||||||
|
{note.text}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Comment contents */}
|
{/* Jump-to Line indicator */}
|
||||||
<div className="text-[#2D2926] text-xs leading-relaxed break-words pr-2 font-sans select-text">
|
<button
|
||||||
{note.comment}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
|
onJumpToLine(note.lineId);
|
||||||
|
}}
|
||||||
|
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
|
||||||
|
title="点击跳转并定位此行"
|
||||||
|
>
|
||||||
|
行 {note.lineNumber.toString().padStart(2, '0')}
|
||||||
|
<ChevronRight className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete button (displays on card hover) */}
|
{/* Comment contents */}
|
||||||
<button
|
<div className="text-[#2D2926] text-xs leading-relaxed break-words pr-2 font-sans select-text">
|
||||||
onClick={(e) => {
|
{note.comment}
|
||||||
e.stopPropagation();
|
</div>
|
||||||
onDeleteNote(note.lineId, note.id);
|
|
||||||
}}
|
{/* Delete button (displays on card hover) */}
|
||||||
title="删除此注释"
|
<button
|
||||||
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<Trash2 className="w-3 h-3" />
|
onDeleteNote(note.lineId, note.id);
|
||||||
</button>
|
}}
|
||||||
</div>
|
title="删除此注释"
|
||||||
);
|
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
|
||||||
})
|
>
|
||||||
) : (
|
<Trash2 className="w-3 h-3" />
|
||||||
<div className="flex flex-col items-center justify-center text-center py-12 text-neutral-400 gap-2">
|
</button>
|
||||||
<FileText className="w-8 h-8 text-neutral-200" />
|
</div>
|
||||||
<div className="text-xs font-medium">无匹配的注释记录</div>
|
);
|
||||||
{searchQuery && (
|
})
|
||||||
<div className="text-[10px] text-neutral-400 px-4">
|
) : (
|
||||||
尝试缩减包含 “{searchQuery}” 的检索词
|
<div className="flex flex-col items-center justify-center text-center py-12 text-neutral-400 gap-2">
|
||||||
</div>
|
<FileText className="w-8 h-8 text-neutral-200" />
|
||||||
)}
|
<div className="text-xs font-medium">暂无注释记录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ interface ToolbarProps {
|
|||||||
onViewSavedDocuments: () => void;
|
onViewSavedDocuments: () => void;
|
||||||
onSaveDocument: () => void;
|
onSaveDocument: () => void;
|
||||||
onToggleBulkImport: () => void;
|
onToggleBulkImport: () => void;
|
||||||
fileStatus: string;
|
|
||||||
showImportArea: boolean;
|
showImportArea: boolean;
|
||||||
showSavedArea: boolean;
|
showSavedArea: boolean;
|
||||||
savedDocumentCount: number;
|
savedDocumentCount: number;
|
||||||
|
canEditDocument: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
@@ -28,33 +28,27 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
onViewSavedDocuments,
|
onViewSavedDocuments,
|
||||||
onSaveDocument,
|
onSaveDocument,
|
||||||
onToggleBulkImport,
|
onToggleBulkImport,
|
||||||
fileStatus,
|
|
||||||
showImportArea,
|
showImportArea,
|
||||||
showSavedArea,
|
showSavedArea,
|
||||||
savedDocumentCount,
|
savedDocumentCount,
|
||||||
|
canEditDocument,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] p-4 flex flex-col xl:flex-row xl:items-center justify-between gap-4 select-none">
|
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-3 py-2 sm:px-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 select-none">
|
||||||
{/* Brand / Logo */}
|
{/* Brand / Logo */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="bg-[#8B7E74] text-white rounded-lg p-2.5 shadow-sm">
|
<div className="bg-[#8B7E74] text-white rounded-lg p-2 shadow-sm">
|
||||||
<BookOpen className="w-5 h-5" />
|
<BookOpen className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-serif font-bold text-[#2D2926] text-lg leading-none tracking-widest flex items-center gap-2">
|
<h1 className="font-serif font-bold text-[#2D2926] text-base sm:text-lg leading-none tracking-widest flex items-center gap-2">
|
||||||
言の葉 <span className="text-[#8B7E74]">Kotonoha</span> 写字板
|
言の葉 写字板
|
||||||
<span className="text-[10px] bg-[#8B7E74]/10 text-[#8B7E74] border border-[#8B7E74]/20 px-2 py-0.5 rounded-full font-sans tracking-normal font-bold">
|
|
||||||
無AI纯净版
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-[#A69F92] font-sans mt-1.5 tracking-tight">
|
|
||||||
在阅览模式下划词记录释义 · {fileStatus}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document actions */}
|
{/* Document actions */}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs select-none font-sans">
|
<div className="flex flex-wrap items-center gap-1.5 text-xs select-none font-sans">
|
||||||
<button
|
<button
|
||||||
onClick={onSaveDocument}
|
onClick={onSaveDocument}
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
|
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
|
||||||
@@ -64,27 +58,31 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<span>保存</span>
|
<span>保存</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{canEditDocument && (
|
||||||
onClick={onToggleBulkImport}
|
<button
|
||||||
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
|
onClick={onToggleBulkImport}
|
||||||
showImportArea
|
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
|
||||||
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
showImportArea
|
||||||
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
||||||
}`}
|
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
||||||
title="粘贴整段文本并按行导入"
|
}`}
|
||||||
>
|
title="粘贴整段文本并按行导入"
|
||||||
<Upload className={`w-3.5 h-3.5 ${showImportArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
>
|
||||||
<span>整段导入</span>
|
<Upload className={`w-3.5 h-3.5 ${showImportArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
</button>
|
<span>整段导入</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
{canEditDocument && (
|
||||||
onClick={onNewDocument}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
|
onClick={onNewDocument}
|
||||||
title="新建空白文档"
|
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
|
||||||
>
|
title="新建空白文档"
|
||||||
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
|
>
|
||||||
<span>新建</span>
|
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
|
||||||
</button>
|
<span>新建</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onViewSavedDocuments}
|
onClick={onViewSavedDocuments}
|
||||||
@@ -93,10 +91,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
|
||||||
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
|
||||||
}`}
|
}`}
|
||||||
title="查看项目 saves 目录里的保存文件"
|
title="打开已保存歌词"
|
||||||
>
|
>
|
||||||
<FolderOpen className={`w-3.5 h-3.5 ${showSavedArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
<FolderOpen className={`w-3.5 h-3.5 ${showSavedArea ? 'text-white' : 'text-[#8B7E74]'}`} />
|
||||||
<span>查看</span>
|
<span>打开</span>
|
||||||
{savedDocumentCount > 0 && (
|
{savedDocumentCount > 0 && (
|
||||||
<span className={`font-mono text-[10px] ${showSavedArea ? 'text-white/80' : 'text-[#A69F92]'}`}>
|
<span className={`font-mono text-[10px] ${showSavedArea ? 'text-white/80' : 'text-[#A69F92]'}`}>
|
||||||
{savedDocumentCount}
|
{savedDocumentCount}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { BookOpen, ChevronDown, Layers3 } from 'lucide-react';
|
||||||
|
import { apiUrl } from '../api';
|
||||||
|
|
||||||
|
interface VocabEntry {
|
||||||
|
comment: string;
|
||||||
|
color: string;
|
||||||
|
lineText: string;
|
||||||
|
lineNumber: number;
|
||||||
|
documentTitle: string;
|
||||||
|
fileName: string;
|
||||||
|
documentId: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VocabCard {
|
||||||
|
text: string;
|
||||||
|
normalizedText: string;
|
||||||
|
noteCount: number;
|
||||||
|
documentTitles: string[];
|
||||||
|
latestComment: string;
|
||||||
|
latestColor: string;
|
||||||
|
entries: VocabEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClassFor = (color: string) => {
|
||||||
|
if (color === 'rose') return 'bg-rose-100 text-rose-950 border-rose-200';
|
||||||
|
if (color === 'sky') return 'bg-sky-100 text-sky-950 border-sky-200';
|
||||||
|
if (color === 'emerald') return 'bg-emerald-100 text-emerald-950 border-emerald-200';
|
||||||
|
if (color === 'violet') return 'bg-purple-100 text-purple-950 border-purple-200';
|
||||||
|
return 'bg-[#E8E4DE] text-[#2D2926] border-[#8B7E74]/30';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VocabCards: React.FC = () => {
|
||||||
|
const [cards, setCards] = useState<VocabCard[]>([]);
|
||||||
|
const [expandedTerm, setExpandedTerm] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadCards = async () => {
|
||||||
|
try {
|
||||||
|
setStatus('loading');
|
||||||
|
const response = await fetch(apiUrl('/api/vocab'));
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch vocab cards');
|
||||||
|
const data = await response.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setCards(Array.isArray(data.cards) ? data.cards : []);
|
||||||
|
setStatus('ready');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading vocab cards', error);
|
||||||
|
if (!cancelled) setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCards();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto bg-[#FAF9F6] px-2 py-3 lg:px-6 lg:pb-6">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-4 py-3 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 font-serif text-lg font-bold text-[#2D2926]">
|
||||||
|
<Layers3 className="h-5 w-5 text-[#8B7E74]" />
|
||||||
|
单词卡片
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-[#A69F92]">
|
||||||
|
按词合并所有歌词注释
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-[#8B7E74]/10 px-3 py-1 text-xs font-semibold text-[#8B7E74]">
|
||||||
|
{cards.length} 词
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
|
||||||
|
正在读取词库...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="rounded-xl border border-rose-200 bg-rose-50 py-10 text-center text-sm text-rose-700">
|
||||||
|
词库读取失败
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'ready' && cards.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
|
||||||
|
还没有单词卡片。给歌词添加注释后会自动出现在这里。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const isExpanded = expandedTerm === card.normalizedText;
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={card.normalizedText}
|
||||||
|
className="rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedTerm(isExpanded ? null : card.normalizedText)}
|
||||||
|
className="flex w-full items-start justify-between gap-3 text-left"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-serif text-xl font-bold text-[#2D2926]">
|
||||||
|
{card.text}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[#A69F92]">
|
||||||
|
<span>{card.noteCount} 条注释</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="truncate">{card.documentTitles.join(' / ')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`mt-1 h-4 w-4 shrink-0 text-[#8B7E74] transition ${
|
||||||
|
isExpanded ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`mt-2 rounded-lg border p-2 text-sm leading-relaxed ${colorClassFor(card.latestColor)}`}>
|
||||||
|
{card.latestComment || '无释义内容'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-3 space-y-2 border-t border-[#E8E4DE]/60 pt-3">
|
||||||
|
{card.entries.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
|
||||||
|
className="rounded-lg border border-[#E8E4DE]/70 bg-white p-2.5"
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-[#8B7E74]">
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
{entry.documentTitle} · 行 {entry.lineNumber}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs leading-relaxed text-[#A69F92]">
|
||||||
|
{entry.lineText}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-2 rounded border px-2 py-1.5 text-sm ${colorClassFor(entry.color)}`}>
|
||||||
|
{entry.comment}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=Noto+Serif+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=Noto+Serif+JP:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-serif: "Noto Serif JP", Georgia, Cambria, serif;
|
--font-serif: "Noto Serif JP", Georgia, Cambria, serif;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -22,5 +22,11 @@
|
|||||||
},
|
},
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
}
|
},
|
||||||
|
"exclude": [
|
||||||
|
"android",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import fs from 'fs/promises';
|
|||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig, Plugin } from 'vite';
|
import { defineConfig, Plugin } from 'vite';
|
||||||
|
import {
|
||||||
|
deleteDocumentByFileName,
|
||||||
|
getDocumentByFileName,
|
||||||
|
getDocumentById,
|
||||||
|
getVocabCards,
|
||||||
|
initDatabase,
|
||||||
|
listDocuments,
|
||||||
|
migrateSavesToDatabase,
|
||||||
|
saveDocument,
|
||||||
|
} from './server/db.js';
|
||||||
|
|
||||||
const SAVES_DIR = path.resolve(__dirname, 'saves');
|
const SAVES_DIR = path.resolve(__dirname, 'saves');
|
||||||
|
|
||||||
@@ -37,52 +47,38 @@ const sendJson = (res: any, statusCode: number, payload: unknown) => {
|
|||||||
const localSavesPlugin = (): Plugin => ({
|
const localSavesPlugin = (): Plugin => ({
|
||||||
name: 'local-saves-api',
|
name: 'local-saves-api',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
|
const ready = (async () => {
|
||||||
|
await initDatabase();
|
||||||
|
await ensureSavesDir();
|
||||||
|
await migrateSavesToDatabase(SAVES_DIR);
|
||||||
|
})();
|
||||||
|
|
||||||
server.middlewares.use(async (req, res, next) => {
|
server.middlewares.use(async (req, res, next) => {
|
||||||
if (!req.url?.startsWith('/api/saves')) {
|
if (!req.url?.startsWith('/api/saves') && !req.url?.startsWith('/api/documents') && !req.url?.startsWith('/api/vocab')) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ready;
|
||||||
await ensureSavesDir();
|
await ensureSavesDir();
|
||||||
const url = new URL(req.url, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const fileNameFromPath = decodeURIComponent(url.pathname.replace(/^\/api\/saves\/?/, ''));
|
const fileNameFromPath = decodeURIComponent(url.pathname.replace(/^\/api\/saves\/?/, ''));
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/saves') {
|
if (req.method === 'GET' && url.pathname === '/api/saves') {
|
||||||
const entries = await fs.readdir(SAVES_DIR);
|
const files = await listDocuments();
|
||||||
const files = await Promise.all(
|
|
||||||
entries
|
|
||||||
.filter((entry) => entry.toLowerCase().endsWith('.json'))
|
|
||||||
.map(async (entry) => {
|
|
||||||
const filePath = path.join(SAVES_DIR, entry);
|
|
||||||
const stat = await fs.stat(filePath);
|
|
||||||
let lineCount: number | null = null;
|
|
||||||
let title = entry.replace(/\.json$/i, '');
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
||||||
lineCount = Array.isArray(parsed?.lines) ? parsed.lines.length : null;
|
|
||||||
title = typeof parsed?.title === 'string' && parsed.title.trim() ? parsed.title : title;
|
|
||||||
} catch {
|
|
||||||
lineCount = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: entry,
|
|
||||||
title,
|
|
||||||
savedAt: stat.mtime.toISOString(),
|
|
||||||
size: stat.size,
|
|
||||||
lineCount,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
files.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime());
|
|
||||||
sendJson(res, 200, { files });
|
sendJson(res, 200, { files });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && fileNameFromPath) {
|
if (req.method === 'GET' && url.pathname.startsWith('/api/saves/') && fileNameFromPath) {
|
||||||
const fileName = sanitizeSaveName(fileNameFromPath);
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
||||||
|
const document = await getDocumentByFileName(fileName);
|
||||||
|
if (document) {
|
||||||
|
sendJson(res, 200, document);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(SAVES_DIR, fileName);
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
sendJson(res, 404, { error: 'Save file not found' });
|
sendJson(res, 404, { error: 'Save file not found' });
|
||||||
@@ -105,19 +101,12 @@ const localSavesPlugin = (): Plugin => ({
|
|||||||
|
|
||||||
const filePath = path.join(SAVES_DIR, fileName);
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
|
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
|
||||||
const stat = await fs.stat(filePath);
|
const file = await saveDocument({ name: fileName, document });
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, { file });
|
||||||
file: {
|
|
||||||
name: fileName,
|
|
||||||
savedAt: stat.mtime.toISOString(),
|
|
||||||
size: stat.size,
|
|
||||||
lineCount: Array.isArray(document.lines) ? document.lines.length : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE' && fileNameFromPath) {
|
if (req.method === 'DELETE' && url.pathname.startsWith('/api/saves/') && fileNameFromPath) {
|
||||||
const fileName = sanitizeSaveName(fileNameFromPath);
|
const fileName = sanitizeSaveName(fileNameFromPath);
|
||||||
const filePath = path.join(SAVES_DIR, fileName);
|
const filePath = path.join(SAVES_DIR, fileName);
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
@@ -125,10 +114,59 @@ const localSavesPlugin = (): Plugin => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
|
await deleteDocumentByFileName(fileName);
|
||||||
sendJson(res, 200, { ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/documents') {
|
||||||
|
const files = await listDocuments();
|
||||||
|
sendJson(res, 200, { files });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/api/documents/')) {
|
||||||
|
const id = decodeURIComponent(url.pathname.replace(/^\/api\/documents\/?/, ''));
|
||||||
|
const document = await getDocumentById(id);
|
||||||
|
if (!document) {
|
||||||
|
sendJson(res, 404, { error: 'Document not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 200, document);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && url.pathname === '/api/documents') {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
const document = body.document;
|
||||||
|
if (!document || typeof document !== 'object') {
|
||||||
|
sendJson(res, 400, { error: 'Missing document payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = await saveDocument({ name: String(body.name || document.title || 'current'), document });
|
||||||
|
await fs.writeFile(path.join(SAVES_DIR, file.name), JSON.stringify(document, null, 2), 'utf-8');
|
||||||
|
sendJson(res, 200, { file });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/api/vocab') {
|
||||||
|
const cards = await getVocabCards();
|
||||||
|
sendJson(res, 200, { cards });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/api/vocab/')) {
|
||||||
|
const term = decodeURIComponent(url.pathname.replace(/^\/api\/vocab\/?/, ''));
|
||||||
|
const cards = await getVocabCards();
|
||||||
|
const card = cards.find((item: any) => item.normalizedText === term || item.text === term);
|
||||||
|
if (!card) {
|
||||||
|
sendJson(res, 404, { error: 'Vocab card not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { card });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
sendJson(res, 405, { error: 'Unsupported saves API request' });
|
sendJson(res, 405, { error: 'Unsupported saves API request' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -140,6 +178,7 @@ const localSavesPlugin = (): Plugin => ({
|
|||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
|
base: './',
|
||||||
plugins: [react(), tailwindcss(), localSavesPlugin()],
|
plugins: [react(), tailwindcss(), localSavesPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||