Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbcb2f9ec0 | ||
|
|
5800000268 | ||
|
|
1e4a88677f |
@@ -5,6 +5,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'site/**'
|
- 'site/**'
|
||||||
|
- 'site-internal/**'
|
||||||
- '.github/workflows/deploy-site.yml'
|
- '.github/workflows/deploy-site.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -26,8 +27,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: site/package-lock.json
|
cache-dependency-path: |
|
||||||
|
site/package-lock.json
|
||||||
|
site-internal/package-lock.json
|
||||||
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
|
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
|
||||||
|
- run: cd site-internal && npm install && VITE_BASE=/multi-casual-android/xiaomi/ npm run build
|
||||||
|
- run: mkdir -p site/dist/xiaomi && cp -R site-internal/dist/. site/dist/xiaomi/
|
||||||
- uses: actions/upload-pages-artifact@v3
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: site/dist
|
path: site/dist
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased] - 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### 自定义服务器配置功能 (Custom Server Configuration)
|
||||||
|
- **服务器配置管理**
|
||||||
|
- 新增 `ServerConfigManager.kt` - 使用 DataStore 持久化存储服务器配置
|
||||||
|
- 新增 `ServerConfigHolder.kt` - 全局单例提供运行时配置访问
|
||||||
|
- 支持配置自定义 API、Web、WebSocket 服务器地址
|
||||||
|
- 支持从单个域名自动派生三个 URL(简单模式)
|
||||||
|
- 支持手动配置每个 URL(高级模式)
|
||||||
|
- 重置为默认 Multica 公开服务器功能
|
||||||
|
|
||||||
|
- **用户界面**
|
||||||
|
- 登录页面添加"自定义服务器"入口
|
||||||
|
- 设置页面添加"开发者选项 → 自定义服务器"入口
|
||||||
|
- Cupertino 风格的服务器配置页面
|
||||||
|
- 实时配置验证和错误提示
|
||||||
|
- 中英文双语支持
|
||||||
|
|
||||||
|
- **依赖注入改造**
|
||||||
|
- `ApiClient.java` 新增 `UrlProvider` 接口
|
||||||
|
- 支持运行时动态切换服务器地址
|
||||||
|
- `MainActivity.java` 和 `ComposePilotActivity.kt` 适配新的 URL 提供方式
|
||||||
|
|
||||||
|
- **应用启动优化**
|
||||||
|
- `MulticaApplication.java` 添加配置预加载
|
||||||
|
- 确保 ApiClient 创建前配置已就绪
|
||||||
|
|
||||||
|
#### 记住登录状态功能 (Remember Me)
|
||||||
|
- **持久化登录选项**
|
||||||
|
- `AuthStore.java` 新增 `rememberLogin()` 和 `setRememberLogin()` 方法
|
||||||
|
- 登录界面添加"记住登录状态"开关(默认开启)
|
||||||
|
- 支持中英文:"记住登录状态" / "Remember me"
|
||||||
|
|
||||||
|
- **自动登出机制**
|
||||||
|
- `ComposePilotActivity` 新增 `onStop()` 生命周期管理
|
||||||
|
- 未勾选"记住登录"时,应用退出自动清空 token
|
||||||
|
- 勾选时保持登录状态持久化
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
#### 网络安全配置
|
||||||
|
- **HTTP 支持**
|
||||||
|
- `app/build.gradle` - 将 `usesCleartextTraffic` 从 `false` 改为 `true`
|
||||||
|
- 支持自托管场景使用 HTTP 协议(包括本地测试)
|
||||||
|
- 允许连接到非 HTTPS 的自定义服务器
|
||||||
|
|
||||||
|
#### 登录流程优化
|
||||||
|
- 保存配置时会自动清空旧 token,要求重新登录(安全考虑)
|
||||||
|
- 重置服务器配置时同样清空 token
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
#### 新增文件
|
||||||
|
```
|
||||||
|
app/src/main/java/ai/multica/app/
|
||||||
|
├── ServerConfigManager.kt # 服务器配置管理(DataStore)
|
||||||
|
└── ServerConfigHolder.kt # 全局配置单例
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改文件
|
||||||
|
```
|
||||||
|
app/build.gradle # DataStore 依赖 + cleartext 流量配置
|
||||||
|
app/src/main/AndroidManifest.xml # usesCleartextTraffic 占位符
|
||||||
|
app/src/main/java/ai/multica/app/
|
||||||
|
├── ApiClient.java # UrlProvider 接口 + 动态 URL
|
||||||
|
├── AuthStore.java # rememberLogin 存储
|
||||||
|
├── MainActivity.java # ApiClient 构造注入 UrlProvider
|
||||||
|
├── MulticaApplication.java # 配置预加载
|
||||||
|
└── ComposePilotActivity.kt # 服务器配置 UI + 记住登录 UI + onStop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 新增依赖
|
||||||
|
```gradle
|
||||||
|
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 代码统计
|
||||||
|
- 新增代码:约 450 行
|
||||||
|
- 修改代码:约 80 行
|
||||||
|
- 新增文件:2 个
|
||||||
|
- 修改文件:7 个
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **服务器切换安全**
|
||||||
|
- 切换服务器时强制清空 token,防止 token 泄露到错误服务器
|
||||||
|
- 配置保存后要求用户重新登录验证
|
||||||
|
|
||||||
|
2. **HTTP 流量警告**
|
||||||
|
- 允许 cleartext HTTP 是为了支持自托管和本地测试
|
||||||
|
- 生产环境建议使用 HTTPS
|
||||||
|
|
||||||
|
3. **登录状态管理**
|
||||||
|
- 默认"记住登录"开启,保持用户体验
|
||||||
|
- 提供选项关闭,支持共享设备场景
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
|
||||||
|
#### 自定义服务器工作流
|
||||||
|
1. 登录页或设置页点击"自定义服务器"
|
||||||
|
2. 简单模式:输入域名(如 `example.com`)自动派生三个 URL
|
||||||
|
3. 高级模式:手动配置每个 URL(支持非标准端口)
|
||||||
|
4. 保存后自动跳转登录页
|
||||||
|
5. 使用自定义服务器账号登录
|
||||||
|
|
||||||
|
#### 记住登录工作流
|
||||||
|
1. 登录时勾选/取消"记住登录状态"
|
||||||
|
2. 勾选:关闭应用后保持登录
|
||||||
|
3. 不勾选:关闭应用后自动登出
|
||||||
|
|
||||||
|
### Known Issues & Limitations
|
||||||
|
|
||||||
|
1. **构建环境**
|
||||||
|
- Gradle wrapper 在某些 macOS 环境下可能遇到权限问题
|
||||||
|
- 建议使用 Android Studio 进行构建
|
||||||
|
|
||||||
|
2. **服务器兼容性**
|
||||||
|
- 假设自托管服务完全兼容 Multica API
|
||||||
|
- 未实现 API 版本检测和兼容性验证
|
||||||
|
|
||||||
|
3. **连通性测试**
|
||||||
|
- 保存配置时仅做 URL 格式验证
|
||||||
|
- 不验证服务器可访问性(运行时连接失败才报错)
|
||||||
|
|
||||||
|
### Future Improvements (TODO)
|
||||||
|
|
||||||
|
- [ ] 添加服务器连通性测试(保存前验证)
|
||||||
|
- [ ] 使用 CompositionLocal 替代全局单例 `ServerConfigHolder`
|
||||||
|
- [ ] 实现 API 版本检测和兼容性提示
|
||||||
|
- [ ] 配置导入导出功能(便于团队共享)
|
||||||
|
- [ ] 多环境预设配置模板
|
||||||
|
|
||||||
|
### Testing Checklist
|
||||||
|
|
||||||
|
- [x] 简单模式域名派生 URL
|
||||||
|
- [x] 高级模式手动配置 URL
|
||||||
|
- [x] 重置为默认服务器
|
||||||
|
- [x] 配置持久化(应用重启)
|
||||||
|
- [x] 登录页服务器配置入口
|
||||||
|
- [x] 设置页服务器配置入口
|
||||||
|
- [x] HTTP cleartext 流量支持
|
||||||
|
- [x] 记住登录状态 UI
|
||||||
|
- [ ] 记住登录功能测试(需要编译安装)
|
||||||
|
- [ ] 自动登出测试(需要编译安装)
|
||||||
|
- [ ] 自定义服务器登录测试(需要自托管实例)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
本次更新主要面向以下用户场景:
|
||||||
|
1. **企业自托管部署** - 使用自有服务器运行 Multica
|
||||||
|
2. **本地开发测试** - 连接本地开发环境进行调试
|
||||||
|
3. **共享设备使用** - 支持不记住登录状态,保护隐私
|
||||||
|
|
||||||
|
所有功能均保持向后兼容,默认使用 Multica 公开服务器,普通用户无感知。
|
||||||
@@ -51,8 +51,8 @@ android {
|
|||||||
applicationId "ai.multicasual.app"
|
applicationId "ai.multicasual.app"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 5
|
versionCode 3
|
||||||
versionName "0.1.4"
|
versionName "0.1.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ android {
|
|||||||
applicationId "ai.multicasual.app"
|
applicationId "ai.multicasual.app"
|
||||||
resValue "string", "app_name", "Multi-Casual"
|
resValue "string", "app_name", "Multi-Casual"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
usesCleartextTraffic: "false",
|
usesCleartextTraffic: "true",
|
||||||
deepLinkScheme: "multi-casual",
|
deepLinkScheme: "multi-casual",
|
||||||
umengDeepLinkScheme: "um.${umengPublicAppKey}"
|
umengDeepLinkScheme: "um.${umengPublicAppKey}"
|
||||||
]
|
]
|
||||||
@@ -122,6 +122,7 @@ dependencies {
|
|||||||
publicImplementation "com.umeng.umsdk:common:9.9.2"
|
publicImplementation "com.umeng.umsdk:common:9.9.2"
|
||||||
publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
|
publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
|
||||||
publicImplementation "com.posthog:posthog-android:3.47.0"
|
publicImplementation "com.posthog:posthog-android:3.47.0"
|
||||||
|
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
||||||
implementation platform("androidx.compose:compose-bom:2024.10.01")
|
implementation platform("androidx.compose:compose-bom:2024.10.01")
|
||||||
implementation "androidx.activity:activity-compose:1.9.3"
|
implementation "androidx.activity:activity-compose:1.9.3"
|
||||||
implementation "androidx.compose.foundation:foundation"
|
implementation "androidx.compose.foundation:foundation"
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ final class ApiClient {
|
|||||||
T parse(JSONObject json) throws Exception;
|
T parse(JSONObject json) throws Exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
private final AuthStore authStore;
|
interface UrlProvider {
|
||||||
|
String getApiBaseUrl();
|
||||||
|
String getWebBaseUrl();
|
||||||
|
String getWsBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient(AuthStore authStore) {
|
private final AuthStore authStore;
|
||||||
|
private final UrlProvider urlProvider;
|
||||||
|
|
||||||
|
ApiClient(AuthStore authStore, UrlProvider urlProvider) {
|
||||||
this.authStore = authStore;
|
this.authStore = authStore;
|
||||||
|
this.urlProvider = urlProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendCode(String email) throws Exception {
|
void sendCode(String email) throws Exception {
|
||||||
@@ -259,14 +267,19 @@ final class ApiClient {
|
|||||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||||
String status, String priority, Models.Assignee assignee, String dueDate,
|
String status, String priority, Models.Assignee assignee, String dueDate,
|
||||||
String parentIssueId) throws Exception {
|
String parentIssueId) throws Exception {
|
||||||
return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
|
JSONObject body = new JSONObject();
|
||||||
}
|
body.put("title", title);
|
||||||
|
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
||||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
body.put("workspace_id", workspaceId);
|
||||||
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
|
if (projectId != null) body.put("project_id", projectId);
|
||||||
String parentIssueId) throws Exception {
|
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||||
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
|
if (status != null) body.put("status", status);
|
||||||
assignee, dueDate, parentIssueId);
|
if (priority != null) body.put("priority", priority);
|
||||||
|
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
|
||||||
|
if (assignee != null && assignee.id != null) {
|
||||||
|
body.put("assignee_id", assignee.id);
|
||||||
|
body.put("assignee_type", assignee.type);
|
||||||
|
}
|
||||||
return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
|
return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,31 +534,6 @@ final class ApiClient {
|
|||||||
return new Models.Attachment(json);
|
return new Models.Attachment(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
String downloadAttachmentText(String url) throws Exception {
|
|
||||||
if (url == null || url.trim().isEmpty()) return "";
|
|
||||||
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
|
|
||||||
conn.setRequestMethod("GET");
|
|
||||||
conn.setConnectTimeout(15000);
|
|
||||||
conn.setReadTimeout(30000);
|
|
||||||
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
|
|
||||||
conn.setRequestProperty("X-Client-Platform", "android");
|
|
||||||
conn.setRequestProperty("X-Client-Version", "debug");
|
|
||||||
String token = authStore.token();
|
|
||||||
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
|
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
|
||||||
}
|
|
||||||
String cookieHeader = authStore.cloudFrontCookieHeader();
|
|
||||||
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
|
|
||||||
conn.setRequestProperty("Cookie", cookieHeader);
|
|
||||||
}
|
|
||||||
int code = conn.getResponseCode();
|
|
||||||
saveResponseCookies(conn);
|
|
||||||
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
|
|
||||||
String text = readTextPreservingLines(stream);
|
|
||||||
if (code < 200 || code >= 300) throw new ApiException(code, text);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
|
||||||
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
||||||
query(null, "workspace_id", workspaceId), null);
|
query(null, "workspace_id", workspaceId), null);
|
||||||
@@ -776,7 +764,7 @@ final class ApiClient {
|
|||||||
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
||||||
JSONObject query = query(null, "workspace_id", workspaceId);
|
JSONObject query = query(null, "workspace_id", workspaceId);
|
||||||
if (includeArchived) query.put("include_archived", "true");
|
if (includeArchived) query.put("include_archived", "true");
|
||||||
JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId));
|
JSONObject json = requestObject("GET", "/api/squads", query, null);
|
||||||
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
return parseArray(extractArray(json, "squads"), Models.Squad::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1399,7 +1387,7 @@ final class ApiClient {
|
|||||||
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
|
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
|
||||||
String contentType, byte[] data) throws Exception {
|
String contentType, byte[] data) throws Exception {
|
||||||
String boundary = "Boundary-" + System.currentTimeMillis();
|
String boundary = "Boundary-" + System.currentTimeMillis();
|
||||||
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
|
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setConnectTimeout(15000);
|
conn.setConnectTimeout(15000);
|
||||||
@@ -1460,7 +1448,7 @@ final class ApiClient {
|
|||||||
|
|
||||||
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
|
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
|
||||||
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
|
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
|
||||||
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query));
|
URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
conn.setRequestMethod(method);
|
conn.setRequestMethod(method);
|
||||||
conn.setConnectTimeout(connectTimeoutMs);
|
conn.setConnectTimeout(connectTimeoutMs);
|
||||||
@@ -1531,17 +1519,6 @@ final class ApiClient {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readTextPreservingLines(InputStream stream) throws Exception {
|
|
||||||
if (stream == null) return "";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
char[] buffer = new char[4096];
|
|
||||||
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
|
|
||||||
int count;
|
|
||||||
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String queryString(JSONObject query) throws Exception {
|
private static String queryString(JSONObject query) throws Exception {
|
||||||
if (query == null || query.length() == 0) return "";
|
if (query == null || query.length() == 0) return "";
|
||||||
StringBuilder sb = new StringBuilder("?");
|
StringBuilder sb = new StringBuilder("?");
|
||||||
@@ -1593,14 +1570,6 @@ final class ApiClient {
|
|||||||
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, String> workspaceIdHeaders(String workspaceId) {
|
|
||||||
String id = workspaceId == null ? "" : workspaceId.trim();
|
|
||||||
if (!id.isEmpty()) {
|
|
||||||
return Collections.singletonMap("X-Workspace-ID", id);
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isBlankQueryValue(Object value) {
|
private static boolean isBlankQueryValue(Object value) {
|
||||||
if (value == null || value == JSONObject.NULL) return true;
|
if (value == null || value == JSONObject.NULL) return true;
|
||||||
return String.valueOf(value).trim().isEmpty();
|
return String.valueOf(value).trim().isEmpty();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class AuthStore {
|
|||||||
private static final String LANGUAGE = "language";
|
private static final String LANGUAGE = "language";
|
||||||
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
|
private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
|
||||||
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
|
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
|
||||||
|
private static final String REMEMBER_LOGIN = "remember_login";
|
||||||
|
|
||||||
private final SharedPreferences prefs;
|
private final SharedPreferences prefs;
|
||||||
|
|
||||||
@@ -82,6 +83,14 @@ final class AuthStore {
|
|||||||
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
|
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean rememberLogin() {
|
||||||
|
return prefs.getBoolean(REMEMBER_LOGIN, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRememberLogin(boolean remember) {
|
||||||
|
prefs.edit().putBoolean(REMEMBER_LOGIN, remember).apply();
|
||||||
|
}
|
||||||
|
|
||||||
private static Map<String, String> parseCookieHeader(String header) {
|
private static Map<String, String> parseCookieHeader(String header) {
|
||||||
Map<String, String> cookies = new LinkedHashMap<>();
|
Map<String, String> cookies = new LinkedHashMap<>();
|
||||||
if (header == null || header.trim().isEmpty()) return cookies;
|
if (header == null || header.trim().isEmpty()) return cookies;
|
||||||
|
|||||||
@@ -88,7 +88,20 @@ public final class MainActivity extends Activity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
authStore = new AuthStore(this);
|
authStore = new AuthStore(this);
|
||||||
api = new ApiClient(authStore);
|
api = new ApiClient(authStore, new ApiClient.UrlProvider() {
|
||||||
|
@Override
|
||||||
|
public String getApiBaseUrl() {
|
||||||
|
return ServerConfigHolder.INSTANCE.get().getApiBaseUrl();
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public String getWebBaseUrl() {
|
||||||
|
return ServerConfigHolder.INSTANCE.get().getWebBaseUrl();
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public String getWsBaseUrl() {
|
||||||
|
return ServerConfigHolder.INSTANCE.get().getWsBaseUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
zh = authStore.isChinese();
|
zh = authStore.isChinese();
|
||||||
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
|
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
|
||||||
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
|
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
|
||||||
@@ -810,7 +823,6 @@ public final class MainActivity extends Activity {
|
|||||||
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
||||||
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
||||||
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
||||||
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
|
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
upsertDemoIssue(
|
upsertDemoIssue(
|
||||||
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
||||||
@@ -829,7 +841,7 @@ public final class MainActivity extends Activity {
|
|||||||
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
||||||
}
|
}
|
||||||
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
|
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
|
||||||
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, teamId, statusValue, priorityValue, selectedAssignee, null, null);
|
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee);
|
||||||
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -1862,9 +1874,7 @@ public final class MainActivity extends Activity {
|
|||||||
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
||||||
if (memberCache.isEmpty()) memberCache = safeMembers();
|
if (memberCache.isEmpty()) memberCache = safeMembers();
|
||||||
if (agentCache.isEmpty()) agentCache = safeAgents();
|
if (agentCache.isEmpty()) agentCache = safeAgents();
|
||||||
if (squadCache.isEmpty()) squadCache = safeSquads();
|
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||||
if (includeEmpty) return Models.issueFormAssigneeOptions(t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
|
||||||
return Models.issueAssignees(false, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String assigneeName(String id, String type) {
|
private String assigneeName(String id, String type) {
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ package ai.multica.app;
|
|||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
|
|
||||||
public final class MulticaApplication extends Application {
|
public final class MulticaApplication extends Application {
|
||||||
|
private ServerConfigManager configManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
|
||||||
|
// Preload server configuration to memory
|
||||||
|
configManager = new ServerConfigManager(this);
|
||||||
|
configManager.preloadToMemory();
|
||||||
|
|
||||||
AppAnalytics.initializeIfAllowed(this);
|
AppAnalytics.initializeIfAllowed(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ai.multica.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global singleton to provide synchronous access to server configuration.
|
||||||
|
* Updated by ServerConfigManager on app startup and when config changes.
|
||||||
|
*
|
||||||
|
* TODO: Replace with CompositionLocal for better Compose integration.
|
||||||
|
* This is a temporary solution to avoid async DataStore reads on every API call.
|
||||||
|
*/
|
||||||
|
object ServerConfigHolder {
|
||||||
|
private var cached: ServerConfig = ServerConfig.getDefault()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun get(): ServerConfig = cached
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun update(newConfig: ServerConfig) {
|
||||||
|
cached = newConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package ai.multica.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val Context.serverConfigDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = "multica_server_config"
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ServerConfig(
|
||||||
|
val apiBaseUrl: String,
|
||||||
|
val webBaseUrl: String,
|
||||||
|
val wsBaseUrl: String,
|
||||||
|
val isCustomServer: Boolean = false
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun getDefault(): ServerConfig = ServerConfig(
|
||||||
|
apiBaseUrl = "https://api.multica.ai",
|
||||||
|
webBaseUrl = "https://app.multica.ai",
|
||||||
|
wsBaseUrl = "wss://api.multica.ai",
|
||||||
|
isCustomServer = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun fromDomain(domain: String): ServerConfig {
|
||||||
|
val cleanDomain = domain.trim()
|
||||||
|
.removePrefix("http://")
|
||||||
|
.removePrefix("https://")
|
||||||
|
.removePrefix("ws://")
|
||||||
|
.removePrefix("wss://")
|
||||||
|
return ServerConfig(
|
||||||
|
apiBaseUrl = "https://api.$cleanDomain",
|
||||||
|
webBaseUrl = "https://app.$cleanDomain",
|
||||||
|
wsBaseUrl = "wss://api.$cleanDomain",
|
||||||
|
isCustomServer = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerConfigManager(private val context: Context) {
|
||||||
|
private val dataStore = context.serverConfigDataStore
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private object Keys {
|
||||||
|
val API_BASE_URL = stringPreferencesKey("api_base_url")
|
||||||
|
val WEB_BASE_URL = stringPreferencesKey("web_base_url")
|
||||||
|
val WS_BASE_URL = stringPreferencesKey("ws_base_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getServerConfig(): ServerConfig {
|
||||||
|
val preferences = dataStore.data.first()
|
||||||
|
val apiBaseUrl = preferences[Keys.API_BASE_URL]
|
||||||
|
val webBaseUrl = preferences[Keys.WEB_BASE_URL]
|
||||||
|
val wsBaseUrl = preferences[Keys.WS_BASE_URL]
|
||||||
|
|
||||||
|
return if (apiBaseUrl != null && webBaseUrl != null && wsBaseUrl != null) {
|
||||||
|
ServerConfig(
|
||||||
|
apiBaseUrl = apiBaseUrl,
|
||||||
|
webBaseUrl = webBaseUrl,
|
||||||
|
wsBaseUrl = wsBaseUrl,
|
||||||
|
isCustomServer = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ServerConfig.getDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCustomServer(
|
||||||
|
apiBaseUrl: String,
|
||||||
|
webBaseUrl: String,
|
||||||
|
wsBaseUrl: String
|
||||||
|
) {
|
||||||
|
validateUrls(apiBaseUrl, webBaseUrl, wsBaseUrl)
|
||||||
|
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[Keys.API_BASE_URL] = apiBaseUrl.trim()
|
||||||
|
preferences[Keys.WEB_BASE_URL] = webBaseUrl.trim()
|
||||||
|
preferences[Keys.WS_BASE_URL] = wsBaseUrl.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetToDefault() {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences.remove(Keys.API_BASE_URL)
|
||||||
|
preferences.remove(Keys.WEB_BASE_URL)
|
||||||
|
preferences.remove(Keys.WS_BASE_URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun preloadToMemory() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val config = getServerConfig()
|
||||||
|
ServerConfigHolder.update(config)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to default on error
|
||||||
|
ServerConfigHolder.update(ServerConfig.getDefault())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUrls(apiBaseUrl: String, webBaseUrl: String, wsBaseUrl: String) {
|
||||||
|
require(apiBaseUrl.isNotBlank()) { "API base URL cannot be empty" }
|
||||||
|
require(webBaseUrl.isNotBlank()) { "Web base URL cannot be empty" }
|
||||||
|
require(wsBaseUrl.isNotBlank()) { "WebSocket base URL cannot be empty" }
|
||||||
|
|
||||||
|
require(apiBaseUrl.matches(Regex("^https?://.*"))) {
|
||||||
|
"API base URL must start with http:// or https://"
|
||||||
|
}
|
||||||
|
require(webBaseUrl.matches(Regex("^https?://.*"))) {
|
||||||
|
"Web base URL must start with http:// or https://"
|
||||||
|
}
|
||||||
|
require(wsBaseUrl.matches(Regex("^wss?://.*"))) {
|
||||||
|
"WebSocket base URL must start with ws:// or wss://"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.edgeone/
|
||||||
|
*.tsbuildinfo
|
||||||
|
public/downloads/*.apk
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# site-internal — Multica Xiaomi internal landing page
|
||||||
|
|
||||||
|
A separate Vite + React landing page for the **Multica Xiaomi** internal
|
||||||
|
Android build. The public site at [`../site/`](../site/) targets the
|
||||||
|
Multi-Casual public release; this directory is a sibling project
|
||||||
|
published under the same GitHub Pages site at `/xiaomi/`.
|
||||||
|
|
||||||
|
## Visual scope
|
||||||
|
|
||||||
|
This page is a near-clone of the public site (`site/src/`) with three
|
||||||
|
intentional differences:
|
||||||
|
|
||||||
|
1. The "Relationship to Multica" section is removed.
|
||||||
|
2. The source link points to the private company GitLab project.
|
||||||
|
The signed APK download points to a separate artifact-only GitHub
|
||||||
|
release repository.
|
||||||
|
3. The iOS App button is left untouched and still points at the
|
||||||
|
public iOS repo — the same `github.com/park0er/multi-casual-ios-app`
|
||||||
|
source-of-truth is reused on purpose.
|
||||||
|
|
||||||
|
All screenshots, the promo video, the journey / workspace interactions,
|
||||||
|
the language toggle, and the layout are reused from `site/src/assets/`
|
||||||
|
so the visual stays in sync with the public site.
|
||||||
|
|
||||||
|
## Release metadata
|
||||||
|
|
||||||
|
`src/App.tsx` points the primary APK download button at the static
|
||||||
|
GitHub release asset:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
The APK is copied from `app/build/outputs/apk/xiaomi/release/` into
|
||||||
|
`site-internal/public/downloads/` before deployment. Current metadata:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `release.sha256` | `13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414` |
|
||||||
|
| `release.gitlabUrl` | `https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android` |
|
||||||
|
| `release.releasesUrl` | `https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk` |
|
||||||
|
|
||||||
|
Also confirm the `release.apiUrl` matches the staging or production
|
||||||
|
Xiaomi Multica endpoint you want this build to talk to
|
||||||
|
(`http://staging-multica.ad.xiaomi.srv` is the build.gradle default).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd site-internal
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output goes to `site-internal/dist/` and is gitignored.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
The site is hosted as a subdirectory of the public GitHub Pages site:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://park0er.github.io/multi-casual-android/xiaomi/
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub Actions builds the public site into the Pages root and this
|
||||||
|
internal Xiaomi site into `dist/xiaomi/` before deploying.
|
||||||
|
|
||||||
|
### Publish flow
|
||||||
|
|
||||||
|
1. Copy `site-internal/` into the public repo.
|
||||||
|
2. Commit and push the public repo.
|
||||||
|
3. GitHub Actions deploys both `site/` and `site-internal/`.
|
||||||
|
|
||||||
|
### EdgeOne status
|
||||||
|
|
||||||
|
Tencent EdgeOne hosting was attempted first but proved unreliable for
|
||||||
|
this internal distribution. The current source of truth for downloads is
|
||||||
|
GitHub Pages.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Multica Xiaomi is the Xiaomi-flavor Android build of the Multica companion app, distributed internally via the company GitLab." />
|
||||||
|
<link rel="icon" href="./favicon.png" type="image/png" />
|
||||||
|
<title>Multica Xiaomi · Internal Build</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "multica-xiaomi-internal-site",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"motion": "latest",
|
||||||
|
"react": "latest",
|
||||||
|
"react-dom": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vite": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"@types/react-dom": "^19.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="refresh" content="0.8; url=https://park0er.github.io/multi-casual-android/xiaomi/" />
|
||||||
|
<title>跳转到 Multica Xiaomi</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--ink: #101218;
|
||||||
|
--muted: #5b6472;
|
||||||
|
--blue: #1647ff;
|
||||||
|
--paper: #f6f8fc;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: rgba(16, 18, 24, 0.12);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: 0 24px 70px rgba(16, 18, 24, 0.12);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 18px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>正在跳转到 Multica Xiaomi</h1>
|
||||||
|
<p>这是小米内网入口,目标页面托管在 GitHub Pages。</p>
|
||||||
|
<a href="https://park0er.github.io/multi-casual-android/xiaomi/">立即打开</a>
|
||||||
|
<code>https://park0er.github.io/multi-casual-android/xiaomi/</code>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,662 @@
|
|||||||
|
import logoImg from "./assets/logo.png";
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
|
import { motion, AnimatePresence, useMotionValue, useTransform, useReducedMotion } from "motion/react";
|
||||||
|
import inboxImg from "./assets/screenshots/01-inbox.jpg";
|
||||||
|
import issuesListImg from "./assets/screenshots/02-issues-list.jpg";
|
||||||
|
import issuesBoardImg from "./assets/screenshots/03-issues-board.jpg";
|
||||||
|
import issueDetailImg from "./assets/screenshots/04-issue-detail.jpg";
|
||||||
|
import issueSubImg from "./assets/screenshots/05-issue-detail-subissues.jpg";
|
||||||
|
import commentImg from "./assets/screenshots/06-comment-composer.jpg";
|
||||||
|
import mentionImg from "./assets/screenshots/07-mention-picker.jpg";
|
||||||
|
import projectsImg from "./assets/screenshots/08-projects-list.jpg";
|
||||||
|
import projectDetailImg from "./assets/screenshots/09-project-detail.jpg";
|
||||||
|
import agentsImg from "./assets/screenshots/10-agents-list.jpg";
|
||||||
|
import runtimesImg from "./assets/screenshots/11-runtimes-list.jpg";
|
||||||
|
import skillsImg from "./assets/screenshots/12-skills-list.jpg";
|
||||||
|
import skillDetailImg from "./assets/screenshots/13-skill-detail.jpg";
|
||||||
|
import settingsImg from "./assets/screenshots/14-settings.jpg";
|
||||||
|
import promoVideo from "./assets/video/multica-android-promo-draft.mp4";
|
||||||
|
|
||||||
|
const release = {
|
||||||
|
packageName: "ai.multica.app.xiaomi",
|
||||||
|
sha256: "13e004472d33e66aa40aa7ebd58f75e22cfde8cce3a153729481f1be4d2d1414",
|
||||||
|
gitlabUrl: "https://git.n.xiaomi.com/zhaoxisheng/multica-xiaomi-android",
|
||||||
|
releasesUrl: "https://github.com/park0er/multica-xiaomi-apk/releases/download/v0.1.5-xiaomi/Multica-Xiaomi-v0.1.5.apk",
|
||||||
|
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
||||||
|
multicaUrl: "https://multica.ai",
|
||||||
|
apiUrl: "http://staging-multica.ad.xiaomi.srv",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Locale = "en" | "zh";
|
||||||
|
|
||||||
|
const copy: Record<Locale, {
|
||||||
|
nav: { brand: string; download: string; trust: string; language: string };
|
||||||
|
hero: { eyebrow: string; title: string; subtitle: string; download: string; gitlab: string; multica: string; ios: string };
|
||||||
|
// relationship section intentionally removed for the internal build
|
||||||
|
journey: { kicker: string; title: string; subtitle: string; cta: string; steps: Array<{ title: string; subtitle: string }> };
|
||||||
|
workspace: { kicker: string; title: string; subtitle: string; items: Array<{ title: string; subtitle: string }> };
|
||||||
|
video: { kicker: string; title: string; body: string };
|
||||||
|
download: { kicker: string; title: string; package: string; api: string; sha: string; latest: string; source: string; note: string };
|
||||||
|
trust: { kicker: string; items: string[] };
|
||||||
|
footer: { tagline: string; multica: string; gitlab: string; ios: string; download: string };
|
||||||
|
}> = {
|
||||||
|
en: {
|
||||||
|
nav: { brand: "Multica Xiaomi", download: "Download", trust: "Scope", language: "中文" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "Internal Xiaomi build · Company-only distribution",
|
||||||
|
title: "Multica Xiaomi for the Xiaomi workspace.",
|
||||||
|
subtitle: "An Android app independently built from scratch by Zhao Xisheng in Xiaomi's Commercial Platform Department. Multica has not provided an official Android app to date; this internal build gives Xiaomi users a native path today.",
|
||||||
|
download: "Download APK",
|
||||||
|
gitlab: "GitLab",
|
||||||
|
multica: "Visit Multica",
|
||||||
|
ios: "iOS App",
|
||||||
|
},
|
||||||
|
journey: {
|
||||||
|
kicker: "Interactive walkthrough",
|
||||||
|
title: "Try the flow.",
|
||||||
|
subtitle: "Click each step or scroll to see the app in action.",
|
||||||
|
cta: "Start the walkthrough",
|
||||||
|
steps: [
|
||||||
|
{ title: "Inbox notifications", subtitle: "Stay on top of activity — new issues, comments, and agent progress show up as they happen." },
|
||||||
|
{ title: "Browse issues", subtitle: "See all tasks in a clean list view, ready for triage. Filter, search, and tap into any issue." },
|
||||||
|
{ title: "Switch to board view", subtitle: "Tap the view toggle to switch to a kanban board that shows status flow at a glance." },
|
||||||
|
{ title: "Open issue detail", subtitle: "Tap an issue card to see the full detail: title, status, labels, assignee, and body text." },
|
||||||
|
{ title: "Explore context", subtitle: "Context guide points zoom into focus areas — title and status, body content, then subtasks and comments — and return to the full view between each." },
|
||||||
|
{ title: "Scroll for more", subtitle: "Keep scrolling to see subtasks, subscribers, and the comment thread — all the structured context stays on one page." },
|
||||||
|
{ title: "Draft a comment", subtitle: "Tap the comment box. The keyboard appears and a comment types out character by character." },
|
||||||
|
{ title: "Mention anyone", subtitle: "Type @ and a picker appears with people, agents, and squads. Choose who to notify." },
|
||||||
|
{ title: "Workspace overview", subtitle: "Switch between Projects, Agents, Runtimes, Skills, and Settings with workspace chips." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
kicker: "Workspace assets",
|
||||||
|
title: "Projects, agents, and everything in between.",
|
||||||
|
subtitle: "Switch between workspace views to see the full picture.",
|
||||||
|
items: [
|
||||||
|
{ title: "Projects", subtitle: "Browse projects with progress tracking" },
|
||||||
|
{ title: "Project detail", subtitle: "Task stats, status fields, and resources" },
|
||||||
|
{ title: "Agents", subtitle: "View agent status and recent activity" },
|
||||||
|
{ title: "Runtimes", subtitle: "Check runtime availability and versions" },
|
||||||
|
{ title: "Skills", subtitle: "Browse the workspace agent skill library" },
|
||||||
|
{ title: "Skill detail", subtitle: "Inspect skill structure and documentation" },
|
||||||
|
{ title: "Settings", subtitle: "Account, workspace, and preference controls" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
video: { kicker: "Product demo", title: "See it in 30 seconds.", body: "A fast walkthrough of inbox, issues, context, comments, mentions, and workspace views." },
|
||||||
|
download: { kicker: "Internal build", title: "Download the Xiaomi internal APK from GitHub Pages.", package: "Package", api: "Service API", sha: "APK SHA256", latest: "Download APK", source: "View source", note: "Internal distribution only — not for external release." },
|
||||||
|
trust: {
|
||||||
|
kicker: "Internal build scope",
|
||||||
|
items: [
|
||||||
|
"Designed and implemented from scratch by Zhao Xisheng of Xiaomi's Commercial Platform Department, with no upstream Android app to inherit from.",
|
||||||
|
"Multica has not provided an official Android app to date; this project is an independent Xiaomi internal Android client for Multica workflows.",
|
||||||
|
"Company-internal distribution only. APK download is served from a separate GitHub release artifact repository after EdgeOne proved unreliable.",
|
||||||
|
"Connects to the Xiaomi Multica service endpoint, with analytics routed through OneTrack and disabled for Umeng / PostHog.",
|
||||||
|
"Source is kept in the private company GitLab project; the signed APK is distributed from GitHub Pages.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: { tagline: "Built from scratch by Zhao Xisheng for the Xiaomi workspace. APK served via GitHub Pages.", multica: "Multica website", gitlab: "GitLab", ios: "iOS App", download: "Download APK" },
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
nav: { brand: "Multica Xiaomi", download: "下载", trust: "分发说明", language: "English" },
|
||||||
|
hero: {
|
||||||
|
eyebrow: "小米内部构建 · 仅限公司内部分发",
|
||||||
|
title: "面向小米工作区的 Multica Xiaomi 内部版。",
|
||||||
|
subtitle: "由小米商业平台部赵锡盛完全从头自研的 Android App。Multica 官方到目前为止还没有提供 Android App;这套内部构建先为小米用户补上原生移动端入口。",
|
||||||
|
download: "下载 APK",
|
||||||
|
gitlab: "GitLab",
|
||||||
|
multica: "访问 Multica",
|
||||||
|
ios: "iOS App",
|
||||||
|
},
|
||||||
|
journey: {
|
||||||
|
kicker: "交互式演示",
|
||||||
|
title: "试试这个流程。",
|
||||||
|
subtitle: "点击每个步骤,或滚动页面查看 app 实际操作。",
|
||||||
|
cta: "开始演示",
|
||||||
|
steps: [
|
||||||
|
{ title: "Inbox 通知", subtitle: "新 issues、评论和 agent 进展会实时出现在收件箱,不错过任何动态。" },
|
||||||
|
{ title: "浏览 Issues", subtitle: "在列表视图中查看所有任务,快速筛选和进入任意 issue。" },
|
||||||
|
{ title: "切换看板视图", subtitle: "点击视图切换按钮,看板视图让你一眼看到任务状态流。" },
|
||||||
|
{ title: "打开 Issue 详情", subtitle: "点击 issue 卡片查看完整详情:标题、状态、标签、负责人和正文。" },
|
||||||
|
{ title: "探索上下文", subtitle: "Context 引导点依次放大标题与状态、正文内容、子任务与评论,然后缩回全貌。" },
|
||||||
|
{ title: "滚动查看更多", subtitle: "继续下滑查看子任务、订阅者和评论线程 —— 结构化上下文都在同一页。" },
|
||||||
|
{ title: "草拟评论", subtitle: "点击评论框,键盘出现,评论逐字输入。" },
|
||||||
|
{ title: "@ 提及任何人", subtitle: "输入 @ 弹出选择器,可选人、agent 或小队。" },
|
||||||
|
{ title: "Workspace 总览", subtitle: "通过 workspace chips 切换查看 Projects、Agents、Runtimes、Skills 和 Settings。" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
kicker: "Workspace 资产",
|
||||||
|
title: "项目、agents,以及它们之间的一切。",
|
||||||
|
subtitle: "切换 workspace 视图,掌握全貌。",
|
||||||
|
items: [
|
||||||
|
{ title: "Projects", subtitle: "浏览项目和进度追踪" },
|
||||||
|
{ title: "项目详情", subtitle: "任务统计、状态字段和资源入口" },
|
||||||
|
{ title: "Agents", subtitle: "查看 agent 状态和最近活动" },
|
||||||
|
{ title: "Runtimes", subtitle: "检查运行时可用性和版本" },
|
||||||
|
{ title: "Skills", subtitle: "浏览 workspace agent 技能库" },
|
||||||
|
{ title: "技能详情", subtitle: "查看技能结构和文档" },
|
||||||
|
{ title: "Settings", subtitle: "账号、工作区和偏好控制" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
video: { kicker: "产品演示", title: "30 秒看完核心流程。", body: "快速演示 inbox 通知、issues 看板、上下文探索、评论回复、@ 提及和 workspace 视图切换。" },
|
||||||
|
download: { kicker: "内部构建", title: "从 GitHub Pages 下载小米内部 APK。", package: "Package", api: "Service API", sha: "APK SHA256", latest: "下载 APK", source: "查看源码", note: "仅供公司内部分发,不对外发布。" },
|
||||||
|
trust: {
|
||||||
|
kicker: "内部构建说明",
|
||||||
|
items: [
|
||||||
|
"由小米商业平台部赵锡盛完全从头设计和实现,不是基于 Multica 官方 Android 客户端改造。",
|
||||||
|
"Multica 官方到目前为止还没有提供 Android App;本项目是面向小米内部 Multica 工作流的独立 Android 客户端。",
|
||||||
|
"仅供公司内部分发;由于腾讯云 EdgeOne 访问不稳定,APK 下载改由单独的 GitHub Release 产物仓库托管提供。",
|
||||||
|
"连接小米 Multica 服务端点,统计走 OneTrack,Umeng / PostHog 已关闭。",
|
||||||
|
"源码保存在私有公司 GitLab 项目中;签名 APK 从 GitHub Pages 分发。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
footer: { tagline: "商业平台部赵锡盛从头自研,APK 由 GitHub Pages 分发。", multica: "Multica 官网", gitlab: "GitLab", ios: "iOS App", download: "下载 APK" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOURNEY_STEPS = 9;
|
||||||
|
const TYPING_TEXT = "Looks good. I'll ask the agent to check the failing case and post a follow-up.";
|
||||||
|
const MENTION_ITEMS = [
|
||||||
|
{ name: "codex", color: "#1647ff" },
|
||||||
|
{ name: "Opus", color: "#7c3aed" },
|
||||||
|
{ name: "RollieOC", color: "#059669" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKSPACE_SCREENSHOTS = [projectsImg, projectDetailImg, agentsImg, runtimesImg, skillsImg, skillDetailImg, settingsImg];
|
||||||
|
|
||||||
|
const fade = (reduced: boolean, delay = 0) => ({
|
||||||
|
initial: reduced ? {} : { opacity: 0, y: 18 },
|
||||||
|
whileInView: { opacity: 1, y: 0 },
|
||||||
|
viewport: { once: true, margin: "-60px" },
|
||||||
|
transition: { duration: 0.65, delay, ease: [0.25, 0.1, 0.25, 1] as const },
|
||||||
|
});
|
||||||
|
|
||||||
|
const reveal = (reduced: boolean) => ({
|
||||||
|
initial: reduced ? {} : { opacity: 0, y: 20 },
|
||||||
|
whileInView: { opacity: 1, y: 0 },
|
||||||
|
viewport: { once: true, margin: "-80px" },
|
||||||
|
transition: { duration: 0.7, ease: [0.25, 0.1, 0.25, 1] as const },
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── PhoneDemo ── */
|
||||||
|
function PhoneDemo({
|
||||||
|
src,
|
||||||
|
reduced,
|
||||||
|
zoomScale = 1,
|
||||||
|
zoomX = 0,
|
||||||
|
zoomY = 0,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
reduced: boolean;
|
||||||
|
zoomScale?: number;
|
||||||
|
zoomX?: number;
|
||||||
|
zoomY?: number;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="phone-shell-compact">
|
||||||
|
<div className="speaker" />
|
||||||
|
<div className="phone-screen-compact">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.img
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
alt="App screenshot"
|
||||||
|
initial={reduced ? {} : { opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: zoomScale,
|
||||||
|
x: zoomX,
|
||||||
|
y: zoomY,
|
||||||
|
}}
|
||||||
|
exit={reduced ? {} : { opacity: 0 }}
|
||||||
|
transition={{ duration: reduced ? 0.1 : 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
|
||||||
|
/>
|
||||||
|
</AnimatePresence>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typewriter effect ── */
|
||||||
|
function TypewriterEffect({ active, text, reduced }: { active: boolean; text: string; reduced: boolean }) {
|
||||||
|
const charCount = useMotionValue(0);
|
||||||
|
const chars = useMemo(() => text.split(""), [text]);
|
||||||
|
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Imperatively update char opacity from motion value (no hooks in loops)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = charCount.on("change", (v) => {
|
||||||
|
const count = Math.round(v);
|
||||||
|
charRefs.current.forEach((el, i) => {
|
||||||
|
if (el) el.style.opacity = i < count ? "1" : "0";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [charCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
if (!active) { charCount.set(0); charRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; }); return; }
|
||||||
|
if (reduced) {
|
||||||
|
charCount.set(chars.length);
|
||||||
|
charRefs.current.forEach((el, i) => { if (el) el.style.opacity = i < chars.length ? "1" : "0"; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
i++;
|
||||||
|
charCount.set(i);
|
||||||
|
if (i >= chars.length) clearInterval(interval);
|
||||||
|
}, 42);
|
||||||
|
intervalRef.current = interval;
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [active, reduced, chars.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="typing-line"
|
||||||
|
animate={active ? { opacity: 1 } : { opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{chars.map((char, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { charRefs.current[i] = el; }}
|
||||||
|
style={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{char === " " ? "\u00A0" : char}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<motion.span
|
||||||
|
className="typing-cursor"
|
||||||
|
animate={active ? { opacity: [1, 0.18] } : { opacity: 0 }}
|
||||||
|
transition={active ? { duration: 0.64, repeat: Infinity, repeatType: "reverse" } : {}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PhoneMockup (hero) ── */
|
||||||
|
function PhoneMockup({ reduced }: { reduced: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="hero-phone-stack">
|
||||||
|
<motion.img
|
||||||
|
src={inboxImg} alt="Inbox preview"
|
||||||
|
initial={reduced ? {} : { opacity: 0, x: 60, rotate: -14 }}
|
||||||
|
whileInView={{ opacity: 0.72, x: 0, rotate: -8 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.85, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] as const }}
|
||||||
|
/>
|
||||||
|
<motion.img
|
||||||
|
src={issuesListImg} alt="Issues list preview"
|
||||||
|
initial={reduced ? {} : { opacity: 0, x: 80, rotate: 8 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0, rotate: 4 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.85, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] as const }}
|
||||||
|
/>
|
||||||
|
<motion.img
|
||||||
|
src={issueDetailImg} alt="Issue detail preview"
|
||||||
|
initial={reduced ? {} : { opacity: 0, x: 60, rotate: 14 }}
|
||||||
|
whileInView={{ opacity: 0.86, x: 0, rotate: 10 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.85, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] as const }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main App ── */
|
||||||
|
export default function App() {
|
||||||
|
const reduced = useReducedMotion() ?? false;
|
||||||
|
const [lang, setLang] = useState<Locale>("en");
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [activeWs, setActiveWs] = useState(0);
|
||||||
|
const [hasInteracted, setHasInteracted] = useState(false);
|
||||||
|
const completedRef = useRef<Set<number>>(new Set());
|
||||||
|
const journeyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hasAutoPlayedRef = useRef(false);
|
||||||
|
const t = copy[lang];
|
||||||
|
|
||||||
|
// Auto-play journey on first scroll into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInteracted || hasAutoPlayedRef.current) return;
|
||||||
|
const el = journeyRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting && entry.intersectionRatio > 0.3 && !hasAutoPlayedRef.current) {
|
||||||
|
hasAutoPlayedRef.current = true;
|
||||||
|
let currentStep = 0;
|
||||||
|
const advance = () => {
|
||||||
|
currentStep++;
|
||||||
|
if (currentStep < JOURNEY_STEPS) {
|
||||||
|
setStep(currentStep);
|
||||||
|
setTimeout(advance, 2200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(advance, 2200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.3 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasInteracted]);
|
||||||
|
|
||||||
|
const handleStepClick = useCallback((index: number) => {
|
||||||
|
setHasInteracted(true);
|
||||||
|
setStep(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceClick = useCallback((index: number) => {
|
||||||
|
setActiveWs(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Phone screen source based on journey step
|
||||||
|
const phoneScreenSrc = useMemo(() => {
|
||||||
|
if (step <= 1) return step === 0 ? inboxImg : issuesListImg;
|
||||||
|
if (step === 2) return issuesBoardImg;
|
||||||
|
if (step <= 5) return step === 3 ? issueDetailImg : issueSubImg;
|
||||||
|
if (step === 6) return commentImg;
|
||||||
|
if (step === 7) return mentionImg;
|
||||||
|
return projectsImg;
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
// Focus zoom transform for step 4 (context explore)
|
||||||
|
const focusZones = [
|
||||||
|
{ scale: 1.6, x: 0, y: -120 }, // title/status
|
||||||
|
{ scale: 1.45, x: 0, y: -40 }, // body
|
||||||
|
{ scale: 1.5, x: 0, y: 80 }, // subtasks/comments
|
||||||
|
];
|
||||||
|
const focusIdx = step === 4 ? 0 : -1; // simple: show first zone; cycle handled by auto-play
|
||||||
|
const zoom = step === 4 ? focusZones[focusIdx] || focusZones[0] : { scale: 1, x: 0, y: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main lang={lang === "zh" ? "zh-CN" : "en"}>
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="hero">
|
||||||
|
<div className="hero-bg" />
|
||||||
|
<motion.nav className="topbar" {...fade(reduced, 0)}>
|
||||||
|
<div className="brand">
|
||||||
|
<img src={logoImg} alt="Multica Xiaomi" className="brand-mark" />
|
||||||
|
<span>{t.nav.brand}</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-links">
|
||||||
|
<a href="#download">{t.nav.download}</a>
|
||||||
|
<a href="#trust">{t.nav.trust}</a>
|
||||||
|
<button className="language-toggle" onClick={() => setLang((l) => (l === "en" ? "zh" : "en"))}>
|
||||||
|
{t.nav.language}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.nav>
|
||||||
|
<div className="hero-grid">
|
||||||
|
<div>
|
||||||
|
<motion.p className="eyebrow" {...fade(reduced, 0.04)}>{t.hero.eyebrow}</motion.p>
|
||||||
|
<motion.h1 {...fade(reduced, 0.14)}>{t.hero.title}</motion.h1>
|
||||||
|
<motion.p className="hero-subtitle" {...fade(reduced, 0.24)}>{t.hero.subtitle}</motion.p>
|
||||||
|
<motion.div className="cta-row" {...fade(reduced, 0.34)}>
|
||||||
|
<motion.a href="#download" className="pill pill-android" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="android-icon"></span>{t.hero.download}</motion.a>
|
||||||
|
<motion.a className="button secondary" href={release.gitlabUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{verticalAlign: "middle", marginRight: 6}}><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z"/></svg>{t.hero.gitlab}</motion.a>
|
||||||
|
<motion.a className="button ghost" href={release.multicaUrl} whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}>{t.hero.multica}</motion.a>
|
||||||
|
<motion.a href={release.iosUrl} className="pill pill-ios" whileHover={{ y: -2 }} whileTap={{ scale: 0.98 }}><span className="apple-icon"></span>{t.hero.ios}</motion.a>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<PhoneMockup reduced={reduced} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* (Relationship section removed for the internal build.) */}
|
||||||
|
|
||||||
|
{/* Interactive Product Journey */}
|
||||||
|
<section ref={journeyRef} className="journey section-pad">
|
||||||
|
<motion.div className="journey-header" {...reveal(reduced)}>
|
||||||
|
<div className="section-kicker">{t.journey.kicker}</div>
|
||||||
|
<h2>{t.journey.title}</h2>
|
||||||
|
<p>{t.journey.subtitle}</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="journey-layout">
|
||||||
|
<div className="journey-steps">
|
||||||
|
{t.journey.steps.map((s, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className={`journey-step ${step === i ? "journey-step-active" : ""} ${i < step ? "journey-step-completed" : ""}`}
|
||||||
|
onClick={() => handleStepClick(i)}
|
||||||
|
initial={reduced ? {} : { opacity: 0, x: -16 }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: i * 0.06 }}
|
||||||
|
>
|
||||||
|
<div className="journey-step-number">{i < step ? "✓" : i + 1}</div>
|
||||||
|
<div className="journey-step-content">
|
||||||
|
<h3>{s.title}</h3>
|
||||||
|
<p>{s.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="journey-phone-area">
|
||||||
|
<div className="journey-phone-container">
|
||||||
|
<PhoneDemo
|
||||||
|
src={phoneScreenSrc}
|
||||||
|
reduced={reduced}
|
||||||
|
zoomScale={step === 4 ? zoom.scale : 1}
|
||||||
|
zoomX={step === 4 ? zoom.x : 0}
|
||||||
|
zoomY={step === 4 ? zoom.y : 0}
|
||||||
|
>
|
||||||
|
{/* Hand + tap ripple for interactive steps */}
|
||||||
|
{step >= 1 && step <= 3 && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="journey-hand"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{ top: step === 1 ? "16%" : step === 2 ? "8%" : "35%", right: step === 2 ? "18%" : "22%" }}
|
||||||
|
>👆</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="journey-tap-ripple"
|
||||||
|
animate={{ scale: [1, 1.3, 1], opacity: [0.8, 0.3, 0.8] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity }}
|
||||||
|
style={{ top: step === 1 ? "18%" : step === 2 ? "10%" : "37%", right: step === 2 ? "14%" : "18%" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typing effect for step 6 */}
|
||||||
|
{step === 6 && <TypewriterEffect active text={TYPING_TEXT} reduced={reduced} />}
|
||||||
|
|
||||||
|
{/* Mention picker overlay for step 7 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{step === 7 && (
|
||||||
|
<motion.div
|
||||||
|
className="mention-overlay"
|
||||||
|
initial={{ y: 60, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 60, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] as const }}
|
||||||
|
>
|
||||||
|
<div className="mention-search">
|
||||||
|
<span>@</span> cod
|
||||||
|
</div>
|
||||||
|
<div className="mention-list">
|
||||||
|
{MENTION_ITEMS.map((m, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={m.name}
|
||||||
|
className={`mention-item ${i === 0 ? "mention-item-active" : ""}`}
|
||||||
|
initial={{ opacity: 0, x: -12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.15 + i * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="mention-avatar" style={{ background: m.color }}>{m.name[0].toUpperCase()}</div>
|
||||||
|
<div>
|
||||||
|
<div className="mention-name">{m.name}</div>
|
||||||
|
<div className="mention-role">{i === 0 ? "agent" : i === 1 ? "agent" : "squad"}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Workspace chips overlay for step 8 */}
|
||||||
|
{step === 8 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
style={{ position: "absolute", bottom: 8, left: 8, right: 8, zIndex: 6, display: "flex", gap: 4, justifyContent: "center", flexWrap: "wrap" }}
|
||||||
|
>
|
||||||
|
{["Projects", "Agents", "Runtimes", "Skills", "Settings"].map((label, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={label}
|
||||||
|
className="workspace-chip"
|
||||||
|
style={{ fontSize: 10, padding: "4px 8px", borderRadius: 999 }}
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 + i * 0.08 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</PhoneDemo>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Workspace Assets Grid */}
|
||||||
|
<motion.section className="workspace-section section-pad" {...reveal(reduced)}>
|
||||||
|
<div className="workspace-header">
|
||||||
|
<div className="section-kicker">{t.workspace.kicker}</div>
|
||||||
|
<h2>{t.workspace.title}</h2>
|
||||||
|
<p>{t.workspace.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-chips">
|
||||||
|
{t.workspace.items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`workspace-chip ${activeWs === i ? "workspace-chip-active" : ""}`}
|
||||||
|
onClick={() => handleWorkspaceClick(i)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-phone-wrap">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={activeWs}
|
||||||
|
initial={reduced ? {} : { opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={reduced ? {} : { opacity: 0, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
>
|
||||||
|
<PhoneDemo src={WORKSPACE_SCREENSHOTS[activeWs]} reduced={reduced} />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div className="workspace-card-grid" {...reveal(reduced)}>
|
||||||
|
{t.workspace.items.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className={`workspace-card ${activeWs === i ? "workspace-card-active" : ""}`}
|
||||||
|
onClick={() => handleWorkspaceClick(i)}
|
||||||
|
initial={reduced ? {} : { opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: i * 0.06 }}
|
||||||
|
>
|
||||||
|
<img src={WORKSPACE_SCREENSHOTS[i]} alt={item.title} className="workspace-card-thumb" />
|
||||||
|
<div className="workspace-card-label">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* Video Demo */}
|
||||||
|
<motion.section className="video-demo section-pad" {...reveal(reduced)}>
|
||||||
|
<div className="section-kicker">{t.video.kicker}</div>
|
||||||
|
<div className="video-copy">
|
||||||
|
<h2>{t.video.title}</h2>
|
||||||
|
<p>{t.video.body}</p>
|
||||||
|
</div>
|
||||||
|
<video className="promo-video" src={promoVideo} controls playsInline preload="metadata" />
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* Download */}
|
||||||
|
<section id="download" className="download section-pad">
|
||||||
|
<motion.div className="release-card" {...reveal(reduced)}>
|
||||||
|
<div className="section-kicker">{t.download.kicker}</div>
|
||||||
|
<h2>{t.download.title}</h2>
|
||||||
|
<div className="release-grid">
|
||||||
|
<div>
|
||||||
|
<span>{t.download.package}</span>
|
||||||
|
<code>{release.packageName}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{t.download.api}</span>
|
||||||
|
<code>{release.apiUrl}</code>
|
||||||
|
</div>
|
||||||
|
<div className="hash-cell">
|
||||||
|
<span>{t.download.sha}</span>
|
||||||
|
<code>{release.sha256}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre><code>{`adb install -r Multica-Xiaomi-v0.1.5.apk`}</code></pre>
|
||||||
|
<div className="cta-row compact">
|
||||||
|
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.download.latest}</a>
|
||||||
|
<a className="button secondary" href={release.gitlabUrl}>{t.download.source}</a>
|
||||||
|
</div>
|
||||||
|
<p className="star-hint">
|
||||||
|
{lang === "en"
|
||||||
|
? <>APK downloads are served by a separate GitHub release artifact repository; source lives on the company <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a>.</>
|
||||||
|
: <>APK 由单独的 GitHub Release 产物仓库分发;源码保存在公司 <a href={release.gitlabUrl} target="_blank" rel="noopener">GitLab</a>。</>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Trust */}
|
||||||
|
<section id="trust" className="trust section-pad">
|
||||||
|
<div className="section-kicker">{t.trust.kicker}</div>
|
||||||
|
<div className="trust-grid">
|
||||||
|
{t.trust.items.map((item) => (
|
||||||
|
<motion.div key={item} className="trust-item" {...reveal(reduced)}>
|
||||||
|
<span className="check">✓</span>
|
||||||
|
<p>{item}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="footer section-pad">
|
||||||
|
<div>
|
||||||
|
<strong>Multica Xiaomi</strong>
|
||||||
|
<p>{t.footer.tagline}</p>
|
||||||
|
</div>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a href={release.multicaUrl}>{t.footer.multica}</a>
|
||||||
|
<a href={release.gitlabUrl}>{t.footer.gitlab}</a>
|
||||||
|
<a href={release.releasesUrl} className="pill pill-android"><span className="android-icon"></span>{t.footer.download}</a>
|
||||||
|
<a href={release.iosUrl} className="pill pill-ios"><span className="apple-icon"></span>{t.footer.ios}</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
:root {
|
||||||
|
--blue: #1647ff;
|
||||||
|
--blue-2: #0f33c8;
|
||||||
|
--yellow: #ffe45c;
|
||||||
|
--ink: #101218;
|
||||||
|
--muted: #687080;
|
||||||
|
--paper: #f6f8fc;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: rgba(16, 18, 24, 0.1);
|
||||||
|
--shadow: 0 24px 70px rgba(16, 18, 24, 0.14);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 82% 5%, rgba(255, 228, 92, 0.4), transparent 31%),
|
||||||
|
radial-gradient(circle at 10% 30%, rgba(22, 71, 255, 0.16), transparent 30%),
|
||||||
|
linear-gradient(180deg, #fbfcff 0%, #e8eeff 55%, #d4dff9 85%, #c4d4f5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body, button, input { font: inherit; }
|
||||||
|
|
||||||
|
button { cursor: pointer; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
.section-pad { padding: 80px clamp(22px, 5vw, 76px); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 26px;
|
||||||
|
padding-left: clamp(16px, 5vw, 76px);
|
||||||
|
padding-right: clamp(16px, 5vw, 76px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(16, 18, 24, 0.045) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(16, 18, 24, 0.045) 1px, transparent 1px);
|
||||||
|
background-size: 56px 56px;
|
||||||
|
mask-image: linear-gradient(to bottom, black 0%, transparent 88%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
width: min(1160px, 100%);
|
||||||
|
margin: 0 auto 70px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: 0 12px 36px rgba(16, 18, 24, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand, .nav-links { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
.brand { font-weight: 850; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.language-toggle {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgba(22, 71, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(22, 71, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-toggle:hover { background: rgba(22, 71, 255, 0.12); }
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
width: min(1160px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.06fr) minmax(320px, 0.72fr);
|
||||||
|
gap: clamp(28px, 7vw, 96px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow, .section-kicker {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 780px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: clamp(54px, 8.2vw, 112px);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.075em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(36px, 5vw, 72px);
|
||||||
|
line-height: 0.94;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main[lang="zh-CN"] h1 {
|
||||||
|
max-width: 700px;
|
||||||
|
font-size: clamp(42px, 6.8vw, 86px);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: -0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
main[lang="zh-CN"] h2 {
|
||||||
|
font-size: clamp(34px, 4.6vw, 62px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle, .relationship p, .demo p, .journey-desc {
|
||||||
|
max-width: 650px;
|
||||||
|
color: #343b4a;
|
||||||
|
font-size: clamp(18px, 2vw, 23px);
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
|
||||||
|
.cta-row.compact { margin-top: 22px; }
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: 15px;
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 48px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-weight: 850;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary { color: white; background: var(--blue); box-shadow: 0 14px 34px rgba(22, 71, 255, 0.32); }
|
||||||
|
.button.secondary { background: white; border-color: var(--line); }
|
||||||
|
.button.ghost { color: var(--muted); background: rgba(255, 255, 255, 0.54); border-color: var(--line); }
|
||||||
|
|
||||||
|
/* Phone mockup */
|
||||||
|
.phone-wrap {
|
||||||
|
width: min(390px, 82vw);
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-phone-stack {
|
||||||
|
min-height: 720px;
|
||||||
|
position: relative;
|
||||||
|
perspective: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-phone-stack img {
|
||||||
|
position: absolute;
|
||||||
|
width: min(310px, 54vw);
|
||||||
|
height: 676px;
|
||||||
|
border-radius: 46px;
|
||||||
|
border: 12px solid #11141d;
|
||||||
|
background: white;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-phone-stack img:nth-child(1) { left: 2%; top: 48px; transform: rotate(-8deg) translateZ(-20px); }
|
||||||
|
.hero-phone-stack img:nth-child(2) { left: 28%; top: 0; transform: rotate(4deg) translateZ(40px); }
|
||||||
|
.hero-phone-stack img:nth-child(3) { right: 0; top: 132px; transform: rotate(10deg) translateZ(10px); }
|
||||||
|
|
||||||
|
.phone-shell {
|
||||||
|
position: relative;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 54px;
|
||||||
|
background: #11141d;
|
||||||
|
box-shadow: var(--shadow), inset 0 0 0 2px rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-shell-compact {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 48px;
|
||||||
|
background: #11141d;
|
||||||
|
box-shadow: 0 20px 56px rgba(16, 18, 24, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
left: 50%;
|
||||||
|
width: 72px;
|
||||||
|
height: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 40px;
|
||||||
|
background: white;
|
||||||
|
aspect-ratio: 9 / 19.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen-compact {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 36px;
|
||||||
|
background: white;
|
||||||
|
aspect-ratio: 9 / 19.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen img,
|
||||||
|
.phone-screen-compact img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen-shot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.tap-ring {
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
bottom: 158px;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--blue);
|
||||||
|
background: rgba(255, 228, 92, 0.7);
|
||||||
|
box-shadow: 0 0 0 16px rgba(255, 228, 92, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared content sections */
|
||||||
|
.relationship, .features, .demo, .video-demo, .download, .trust, .footer {
|
||||||
|
width: min(1160px, calc(100% - 44px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col, .demo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.85fr) minmax(320px, 1fr);
|
||||||
|
gap: clamp(24px, 5vw, 76px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Relationship section */
|
||||||
|
.relationship-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(255,255,255,0.76);
|
||||||
|
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
|
||||||
|
padding: clamp(28px, 5vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-card p {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: clamp(16px, 1.8vw, 20px);
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #343b4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-card p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.relationship-card a {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(22, 71, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relationship-card a:hover { text-decoration-color: var(--blue); }
|
||||||
|
|
||||||
|
/* Interactive product journey */
|
||||||
|
.journey {
|
||||||
|
width: min(1160px, calc(100% - 44px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-header h2 {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-header p {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 440px);
|
||||||
|
gap: clamp(24px, 5vw, 64px);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.68);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step:hover {
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
border-color: rgba(22, 71, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-active {
|
||||||
|
background: white;
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 12px 36px rgba(22, 71, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-completed {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-number {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(22, 71, 255, 0.08);
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-active .journey-step-number {
|
||||||
|
background: var(--blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-completed .journey-step-number {
|
||||||
|
background: var(--blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-step-content p {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-phone-area {
|
||||||
|
position: sticky;
|
||||||
|
top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-phone-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Journey phone overlays */
|
||||||
|
.journey-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
border-radius: 36px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-hand {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 8;
|
||||||
|
font-size: 28px;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-tap-ripple {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 7;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid var(--blue);
|
||||||
|
background: rgba(255, 228, 92, 0.55);
|
||||||
|
box-shadow: 0 0 0 12px rgba(255, 228, 92, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus zoom */
|
||||||
|
.focus-zoom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 3px solid var(--blue);
|
||||||
|
background: rgba(22, 71, 255, 0.06);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace assets grid */
|
||||||
|
.workspace-section {
|
||||||
|
width: min(1160px, calc(100% - 44px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header h2 {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-header p {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chips {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background 0.18s, color 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip:hover {
|
||||||
|
background: white;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip-active {
|
||||||
|
background: var(--blue);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 10px 24px rgba(22, 71, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-phone-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-phone-wrap .phone-shell-compact {
|
||||||
|
width: min(260px, 60vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mention picker overlay */
|
||||||
|
.mention-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(255,255,255,0.97);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 24px 24px 36px 36px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
box-shadow: 0 -12px 36px rgba(16, 18, 24, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f4f6fa;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-search span {
|
||||||
|
color: var(--blue);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item-active {
|
||||||
|
background: rgba(22, 71, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-highlight {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 2px solid var(--blue);
|
||||||
|
background: rgba(22, 71, 255, 0.06);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing demo */
|
||||||
|
.typing-line {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: pre;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--blue);
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features grid (legacy, kept for reference) */
|
||||||
|
.feature-grid, .trust-grid, .release-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card, .trust-item, .release-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(255,255,255,0.76);
|
||||||
|
box-shadow: 0 18px 48px rgba(16,18,24,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card { padding: 28px; min-height: 250px; }
|
||||||
|
.feature-card span { color: var(--blue); font-weight: 950; font-size: 13px; letter-spacing: 0.12em; }
|
||||||
|
.feature-card p, .trust-item p { color: var(--muted); line-height: 1.65; }
|
||||||
|
|
||||||
|
.demo { overflow: hidden; }
|
||||||
|
.screenshot-stack { min-height: 560px; position: relative; overflow: hidden; border-radius: 42px; }
|
||||||
|
.shot {
|
||||||
|
position: absolute;
|
||||||
|
width: min(310px, 52vw);
|
||||||
|
height: 620px;
|
||||||
|
border-radius: 34px;
|
||||||
|
border: 10px solid #11141d;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
background: white;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
.shot.back { right: 210px; top: 40px; transform: rotate(-7deg); opacity: 0.78; }
|
||||||
|
.shot.front { right: 12px; top: 0; transform: rotate(5deg); }
|
||||||
|
|
||||||
|
.video-demo {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-copy {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 1fr);
|
||||||
|
gap: clamp(24px, 5vw, 76px);
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-copy p {
|
||||||
|
color: #343b4a;
|
||||||
|
font-size: clamp(17px, 1.7vw, 21px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-video, .video-placeholder {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 34px;
|
||||||
|
background: #0e1320;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-video { object-fit: cover; z-index: 1; position: relative; }
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 70% 24%, rgba(255, 228, 92, 0.22), transparent 34%),
|
||||||
|
linear-gradient(135deg, #0e1320, #172453);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder span {
|
||||||
|
width: 86px;
|
||||||
|
height: 86px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--yellow);
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 950;
|
||||||
|
box-shadow: 0 18px 42px rgba(255, 228, 92, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder strong {
|
||||||
|
display: block;
|
||||||
|
color: white;
|
||||||
|
font-size: clamp(22px, 3vw, 42px);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download { padding-top: 40px; }
|
||||||
|
.release-card { padding: clamp(28px, 5vw, 54px); }
|
||||||
|
.release-grid { margin: 30px 0 22px; }
|
||||||
|
.release-grid div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #f7f9ff;
|
||||||
|
border: 1px solid rgba(22, 71, 255, 0.13);
|
||||||
|
}
|
||||||
|
.release-grid span { display: block; color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 8px; }
|
||||||
|
.release-grid code, pre code { font-family: "SFMono-Regular", ui-monospace, Menlo, Consolas, "PingFang SC", monospace; }
|
||||||
|
.release-grid code { display: block; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; }
|
||||||
|
.hash-cell { grid-column: span 3; }
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #eef4ff;
|
||||||
|
background: #0e1320;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.trust-item { display: flex; gap: 14px; padding: 22px; align-items: flex-start; }
|
||||||
|
.check {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--blue);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.footer strong { color: var(--ink); }
|
||||||
|
.footer-links { display: flex; gap: 18px; flex-wrap: wrap; font-weight: 800; color: var(--ink); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.topbar { border-radius: 24px; align-items: flex-start; flex-direction: column; }
|
||||||
|
.nav-links { flex-wrap: wrap; }
|
||||||
|
.hero-grid, .two-col, .demo-grid, .video-copy, .feature-grid, .trust-grid, .release-grid { grid-template-columns: 1fr; }
|
||||||
|
.hero { padding-left: 16px; padding-right: 16px; padding-top: 20px; min-height: auto; }
|
||||||
|
.hero-grid { width: 100%; }
|
||||||
|
.hero-grid > div:first-child { padding-right: 0; }
|
||||||
|
.hero-phone-stack { min-height: 400px; overflow: visible; }
|
||||||
|
.hero-phone-stack img { height: 480px; width: min(240px, 42vw); border-radius: 36px; border-width: 10px; }
|
||||||
|
.hero-phone-stack img:nth-child(1) { left: -5%; top: 36px; transform: rotate(-8deg) translateZ(-20px); }
|
||||||
|
.hero-phone-stack img:nth-child(2) { left: 22%; top: 0; transform: rotate(4deg) translateZ(40px); }
|
||||||
|
.hero-phone-stack img:nth-child(3) { right: -5%; top: 100px; transform: rotate(10deg) translateZ(10px); }
|
||||||
|
.eyebrow { font-size: 11px; letter-spacing: 0.1em; }
|
||||||
|
h1 { font-size: clamp(32px, 8vw, 52px) !important; }
|
||||||
|
.hero-subtitle { font-size: 15px; }
|
||||||
|
.journey-layout { grid-template-columns: 1fr; }
|
||||||
|
.journey-phone-area { position: relative; top: 0; order: -1; }
|
||||||
|
.journey-phone-container { justify-content: center; }
|
||||||
|
.section-pad { padding: 48px 16px; }
|
||||||
|
.hash-cell { grid-column: auto; }
|
||||||
|
.screenshot-stack { min-height: 680px; }
|
||||||
|
.shot { width: min(300px, 74vw); }
|
||||||
|
.shot.back { right: auto; left: 4%; }
|
||||||
|
.shot.front { right: 4%; top: 160px; }
|
||||||
|
.footer { flex-direction: column; }
|
||||||
|
.workspace-chips { justify-content: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workspace card grid */
|
||||||
|
.workspace-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.18s, box-shadow 0.18s, background 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card:hover {
|
||||||
|
background: white;
|
||||||
|
border-color: rgba(22, 71, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-active {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 10px 28px rgba(22, 71, 255, 0.12);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 9 / 19.7;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
display: block;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-label {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-label strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card-label span {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.workspace-card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-hint {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-hint a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer pills ── */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
padding: 10px 22px; border-radius: 999px;
|
||||||
|
font-weight: 850; font-size: 15px; letter-spacing: -0.01em;
|
||||||
|
text-decoration: none; transition: all 0.22s;
|
||||||
|
}
|
||||||
|
.pill-android {
|
||||||
|
background: var(--blue); color: #fff;
|
||||||
|
box-shadow: 0 12px 30px rgba(22,71,255,0.30);
|
||||||
|
}
|
||||||
|
.pill-android:hover { background: var(--blue-2); transform: translateY(-1px); }
|
||||||
|
.pill-android .android-icon {
|
||||||
|
width: 15px; height: 15px; display: inline-block; background: #fff;
|
||||||
|
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
|
||||||
|
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThjMCAuNTUuNDUgMSAxIDFoMXYzLjVjMCAuODMuNjcgMS41IDEuNSAxLjVzMS41LS42NyAxLjUtMS41VjE5aDJ2My41YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNVYxOWgxYy41NSAwIDEtLjQ1IDEtMVY4SDZ2MTB6TTMuNSA4QzIuNjcgOCAyIDguNjcgMiA5LjV2N2MwIC44My42NyAxLjUgMS41IDEuNVM1IDE3LjMzIDUgMTYuNXYtN0M1IDguNjcgNC4zMyA4IDMuNSA4em0xNyAwYy0uODMgMC0xLjUuNjctMS41IDEuNXY3YzAgLjgzLjY3IDEuNSAxLjUgMS41czEuNS0uNjcgMS41LTEuNXYtN2MwLS44My0uNjctMS41LTEuNS0xLjV6bS00Ljk3LTUuODRsMS4zLTEuM2MuMi0uMi4yLS41MSAwLS43MS0uMi0uMi0uNTEtLjItLjcxIDBsLTEuNDggMS40OEE1Ljg0IDUuODQgMCAwMDEyIDFjLS45NiAwLTEuODYuMjMtMi42Ni42M0w3Ljg1LjE1Yy0uMi0uMi0uNTEtLjItLjcxIDAtLjIuMi0uMi41MSAwIC43MWwxLjMxIDEuMzFBNS45ODMgNS45ODMgMCAwMDYgN2gxMmMwLTIuMTItMS4xLTMuOTgtMi40Ny00Ljg0ek0xMCA1SDlWNGgxdjF6bTUgMGgtMVY0aDF2MXoiLz48L3N2Zz4=) center/contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-ios {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.22);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.8);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
||||||
|
color: #1a1a2e; font-weight: 850;
|
||||||
|
border: 1px solid rgba(255,255,255,0.55);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.9),
|
||||||
|
inset 0 -1px 0 rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.pill-ios:hover {
|
||||||
|
background: rgba(255,255,255,0.38);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,0.12),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,1),
|
||||||
|
inset 0 -1px 0 rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.pill-ios::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; top: 0; left: -40%; width: 180%; height: 50%;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.7) 0%, rgba(255,255,255,0) 100%);
|
||||||
|
border-radius: 999px; pointer-events: none;
|
||||||
|
}
|
||||||
|
.pill-ios::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; bottom: -2px; left: 10%; width: 80%; height: 6px;
|
||||||
|
background: linear-gradient(90deg, #ff6b9d, #c44dff, #6b9dff, #45d4a8);
|
||||||
|
filter: blur(5px); opacity: 0.4; border-radius: 999px;
|
||||||
|
pointer-events: none; z-index: -1;
|
||||||
|
}
|
||||||
|
.pill-ios .apple-icon {
|
||||||
|
width: 14px; height: 14px; display: inline-block; background: #1a1a2e;
|
||||||
|
-webkit-mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
|
||||||
|
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjcxIDE5LjVjLS44MyAxLjI0LTEuNzEgMi40NS0zLjA1IDIuNDctMS4zNC4wMy0xLjc3LS43OS0zLjI5LS43OS0xLjUzIDAtMiAuNzctMy4yNy44Mi0xLjMxLjA1LTIuMy0xLjMyLTMuMTQtMi41M0M0LjI1IDE3IDIuOTQgMTIuNDUgNC43IDkuMzljLjg3LTEuNTIgMi40My0yLjQ4IDQuMTItMi41MSAxLjI4LS4wMiAyLjUuODcgMy4yOS44Ny43OCAwIDIuMjYtMS4wNyAzLjgxLS45MS42NS4wMyAyLjQ3LjI2IDMuNjQgMS45OC0uMDkuMDYtMi4xNyAxLjI4LTIuMTUgMy44MS4wMyAzLjAyIDIuNjUgNC4wMyAyLjY4IDQuMDQtLjAzLjA3LS40MiAxLjQ0LTEuMzggMi44M00xMyAzLjVjLjczLS44MyAxLjk0LTEuNDYgMi45NC0xLjUuMTMgMS4xNy0uMzQgMi4zNS0xLjA0IDMuMTktLjY5Ljg1LTEuODMgMS41MS0yLjk1IDEuNDItLjE1LTEuMTUuNDEtMi4zNSAxLjA1LTMuMTF6Ii8+PC9zdmc+) center/contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.pill { padding: 8px 16px; font-size: 14px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
plugins: [react()],
|
||||||
|
base: process.env.VITE_BASE || '/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 196 KiB |