Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9b256ab1 | ||
|
|
937646ac43 | ||
|
|
cbff11728f | ||
|
|
652c4826b1 | ||
|
|
3bbb7fa40c | ||
|
|
af7e92b934 | ||
|
|
ddd2d01da6 | ||
|
|
9fe67e13b1 | ||
|
|
83f40200a9 | ||
|
|
d0fe07ff3b | ||
|
|
fb7c7d213b | ||
|
|
d2c0812fda | ||
|
|
9dad8bef62 | ||
|
|
9b4c93c71b | ||
|
|
6dcba1713d | ||
|
|
75201cf0f8 | ||
|
|
62e1da8787 | ||
|
|
362cd3078c | ||
|
|
0b1d7ab56b | ||
|
|
27250ed42d | ||
|
|
832a7e0ea0 | ||
|
|
3a4e2be30d | ||
|
|
4003f794e0 | ||
|
|
c4dbf03f78 | ||
|
|
8377c9fa9d | ||
|
|
9615244519 | ||
|
|
eb75018d9b | ||
|
|
6727b84547 |
@@ -1,43 +0,0 @@
|
||||
name: Deploy Marketing Site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'site/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- run: cd site && npm install && VITE_BASE=/multi-casual-android/ npm run build
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -9,15 +9,6 @@ This repository publishes an Android app that works with the Multica backend ser
|
||||
The app exists for users who already use Multica and want the core workspace workflow on Android: issue browsing, issue details, comments, projects, agents, skills, labels, and settings.
|
||||
|
||||
- Multica website: https://multica.ai
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><b>cn</b></td><td><a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">multi-casual-china-8temdtzs.zh-cn.edgeone.cool</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>global</b></td><td><a href="https://multi-casual-android.pages.dev/">multi-casual-android.pages.dev</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
- Multica web app: https://app.multica.ai
|
||||
- Service API used by this app: `https://api.multica.ai`
|
||||
- Android package: `ai.multicasual.app`
|
||||
@@ -28,7 +19,7 @@ Multica is the original product and backend service. Multi-Casual Android is an
|
||||
|
||||
The name “Multi-Casual” is used here to describe this Android client and its compatibility with Multica workflows. Product ownership, trademarks, service availability, and backend behavior remain with the Multica project and its maintainers.
|
||||
|
||||
This project follows the public guidance shared by the Multica maintainers about keeping Android app work independent and clearly attributed. See the related discussion: https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596
|
||||
If you represent Multica and would like wording or attribution adjusted, please open an issue. The goal is to keep the relationship accurate, respectful, and clear.
|
||||
|
||||
## What this app does
|
||||
|
||||
@@ -54,8 +45,8 @@ You can also build the app without analytics keys. In that case, analytics provi
|
||||
For normal installation, download the signed release APK from GitHub Releases:
|
||||
|
||||
- Latest release: https://github.com/park0er/multi-casual-android/releases/latest
|
||||
- Current signed APK: `Multi-Casual-Android-v0.1.4.apk`
|
||||
- SHA256: `35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f`
|
||||
- Current signed APK: `Multi-Casual-Android-v0.1.1.apk`
|
||||
- SHA256: `0b34ab0d164403aec655ca9e829b3fce6a2849f4a83dc1f631336ff725299c42`
|
||||
|
||||
The debug APK produced by local development commands is not intended for public distribution.
|
||||
|
||||
@@ -144,7 +135,7 @@ Multica 是原始产品和后端服务。Multi-Casual Android 是面向该服务
|
||||
|
||||
这里使用 “Multi-Casual” 名称,是为了说明这个 Android 客户端及其与 Multica 工作流的兼容关系。产品归属、商标、服务可用性和后端行为都仍属于 Multica 项目及其维护者。
|
||||
|
||||
这个项目遵循了 Multica maintainers 在公开 issue 中给出的 guidance:Android app 工作应保持独立维护、清晰署名并尊重原项目边界。相关讨论见:https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596
|
||||
如果你代表 Multica,并希望调整措辞或署名方式,欢迎开 issue。这个仓库希望把关系说明得准确、礼貌、清楚。
|
||||
|
||||
## App 能做什么
|
||||
|
||||
@@ -170,8 +161,8 @@ Analytics 是 opt-in。App 会先请求用户同意,然后才会上报 analyti
|
||||
普通用户安装时,请从 GitHub Releases 下载签名 release APK:
|
||||
|
||||
- 最新 release:https://github.com/park0er/multi-casual-android/releases/latest
|
||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.4.apk`
|
||||
- SHA256:`35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f`
|
||||
- 当前签名 APK:`Multi-Casual-Android-v0.1.1.apk`
|
||||
- SHA256:`0b34ab0d164403aec655ca9e829b3fce6a2849f4a83dc1f631336ff725299c42`
|
||||
|
||||
本地开发命令生成的 debug APK 不用于公开分发。
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ android {
|
||||
applicationId "ai.multicasual.app"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
versionName "0.1.4"
|
||||
versionCode 3
|
||||
versionName "0.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -259,14 +259,19 @@ final class ApiClient {
|
||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String status, String priority, Models.Assignee assignee, String dueDate,
|
||||
String parentIssueId) throws Exception {
|
||||
return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId);
|
||||
}
|
||||
|
||||
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
|
||||
String parentIssueId) throws Exception {
|
||||
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
|
||||
assignee, dueDate, parentIssueId);
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("title", title);
|
||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
||||
body.put("workspace_id", workspaceId);
|
||||
if (projectId != null) body.put("project_id", projectId);
|
||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||
if (status != null) body.put("status", status);
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -521,31 +526,6 @@ final class ApiClient {
|
||||
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 {
|
||||
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
|
||||
query(null, "workspace_id", workspaceId), null);
|
||||
@@ -776,7 +756,7 @@ final class ApiClient {
|
||||
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
|
||||
JSONObject query = query(null, "workspace_id", workspaceId);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1531,17 +1511,6 @@ final class ApiClient {
|
||||
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 {
|
||||
if (query == null || query.length() == 0) return "";
|
||||
StringBuilder sb = new StringBuilder("?");
|
||||
@@ -1593,14 +1562,6 @@ final class ApiClient {
|
||||
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) {
|
||||
if (value == null || value == JSONObject.NULL) return true;
|
||||
return String.valueOf(value).trim().isEmpty();
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
final class AttachmentPreview {
|
||||
static final String BASE_URL = "https://attachment-preview.local/";
|
||||
|
||||
enum Kind {
|
||||
MARKDOWN,
|
||||
HTML,
|
||||
XML,
|
||||
DOWNLOAD
|
||||
}
|
||||
|
||||
private AttachmentPreview() {}
|
||||
|
||||
static Kind kindFor(String contentType, String filename) {
|
||||
String type = mediaType(contentType);
|
||||
if (isMarkdownType(type)) return Kind.MARKDOWN;
|
||||
if (isHtmlType(type)) return Kind.HTML;
|
||||
if (isXmlType(type)) return Kind.XML;
|
||||
|
||||
String name = filename == null ? "" : filename.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (name.endsWith(".md") || name.endsWith(".markdown")) return Kind.MARKDOWN;
|
||||
if (name.endsWith(".html") || name.endsWith(".htm")) return Kind.HTML;
|
||||
if (name.endsWith(".xml")) return Kind.XML;
|
||||
return Kind.DOWNLOAD;
|
||||
}
|
||||
|
||||
static boolean isLocalPreviewUrl(String url) {
|
||||
if (url == null || url.trim().isEmpty()) return false;
|
||||
try {
|
||||
URI uri = URI.create(url.trim());
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
return "https".equalsIgnoreCase(scheme) && "attachment-preview.local".equalsIgnoreCase(host);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isWebViewInternalDocumentUrl(String url) {
|
||||
if (url == null) return false;
|
||||
String value = url.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return value.equals("about:blank") || value.startsWith("data:text/html");
|
||||
}
|
||||
|
||||
static String xmlPreviewDocument(String xml) {
|
||||
return "<!doctype html><html><head><meta charset=\"utf-8\">"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||
+ "<style>body{margin:0;padding:16px;background:#fff;color:#111;font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;}pre{white-space:pre-wrap;word-break:break-word;margin:0;}</style>"
|
||||
+ "</head><body><pre>"
|
||||
+ escapeHtml(xml == null ? "" : xml)
|
||||
+ "</pre></body></html>";
|
||||
}
|
||||
|
||||
private static String mediaType(String contentType) {
|
||||
if (contentType == null) return "";
|
||||
int semicolon = contentType.indexOf(';');
|
||||
String value = semicolon >= 0 ? contentType.substring(0, semicolon) : contentType;
|
||||
return value.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean isMarkdownType(String type) {
|
||||
return "text/markdown".equals(type)
|
||||
|| "text/x-markdown".equals(type)
|
||||
|| "application/markdown".equals(type)
|
||||
|| "application/x-markdown".equals(type);
|
||||
}
|
||||
|
||||
private static boolean isHtmlType(String type) {
|
||||
return "text/html".equals(type) || "application/xhtml+xml".equals(type);
|
||||
}
|
||||
|
||||
private static boolean isXmlType(String type) {
|
||||
return "text/xml".equals(type) || "application/xml".equals(type) || type.endsWith("+xml");
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.multica.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -13,11 +12,6 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -36,7 +30,6 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -295,22 +288,6 @@ class ComposePilotActivity : ComponentActivity() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_attachment_markdown", false)) {
|
||||
setContent {
|
||||
MulticaTheme {
|
||||
PilotAttachmentMarkdownPreviewFixture()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_attachment_html", false)) {
|
||||
setContent {
|
||||
MulticaTheme {
|
||||
PilotAttachmentHtmlPreviewFixture()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (intent.getBooleanExtra("preview_no_workspace_onboarding", false)) {
|
||||
val previewCreationSuccess = intent.getBooleanExtra("preview_no_workspace_success", false)
|
||||
val previewUser = Models.User(JSONObject()
|
||||
@@ -451,56 +428,12 @@ private data class PilotSkillDetailData(
|
||||
val files: List<Models.SkillFile>,
|
||||
)
|
||||
|
||||
private data class PilotMentionOption(
|
||||
private data class PilotAssigneeOption(
|
||||
val id: String?,
|
||||
val type: String?,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
private fun pilotMentionOptions(
|
||||
includeEmpty: Boolean,
|
||||
emptyLabel: String,
|
||||
currentUser: Models.User?,
|
||||
members: List<Models.Member>,
|
||||
agents: List<Models.Agent>,
|
||||
squads: List<Models.Squad>,
|
||||
query: String = "",
|
||||
): List<PilotMentionOption> {
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
return Models.issueAssignees(includeEmpty, emptyLabel, currentUser, members, agents, squads)
|
||||
.filter { option ->
|
||||
normalizedQuery.isBlank() ||
|
||||
option.name.lowercase().contains(normalizedQuery) ||
|
||||
option.id.orEmpty().lowercase().contains(normalizedQuery)
|
||||
}
|
||||
.map { PilotMentionOption(it.id, it.type, it.name) }
|
||||
}
|
||||
|
||||
private fun pilotIssueFormAssigneeOptions(
|
||||
emptyLabel: String,
|
||||
currentUser: Models.User?,
|
||||
members: List<Models.Member>,
|
||||
agents: List<Models.Agent>,
|
||||
squads: List<Models.Squad>,
|
||||
): List<PilotMentionOption> {
|
||||
return Models.issueFormAssigneeOptions(emptyLabel, currentUser, members, agents, squads)
|
||||
.map { PilotMentionOption(it.id, it.type, it.name) }
|
||||
}
|
||||
|
||||
private fun pilotMentionTypeLabel(type: String?, zh: Boolean): String = when (type) {
|
||||
"member" -> if (zh) "人" else "Person"
|
||||
"agent" -> "Agent"
|
||||
"squad" -> if (zh) "小队" else "Squad"
|
||||
else -> if (zh) "无" else "None"
|
||||
}
|
||||
|
||||
private fun pilotMentionTypeColor(type: String?): Color = when (type) {
|
||||
"member" -> Color(0xFF16A34A)
|
||||
"agent" -> MulticaColors.Accent
|
||||
"squad" -> MulticaColors.Warning
|
||||
else -> MulticaColors.Muted
|
||||
}
|
||||
|
||||
private val PILOT_REACTION_EMOJIS = listOf("👍", "❤️", "👀", "🎉")
|
||||
private const val PILOT_INBOX_PAGE_SIZE = 20
|
||||
|
||||
@@ -2067,7 +2000,6 @@ private fun PilotTabContent(
|
||||
workspaceId = session.workspace.id,
|
||||
workspace = session.workspace,
|
||||
projects = state.projects,
|
||||
currentUser = session.user,
|
||||
zh = zh,
|
||||
onProjectClick = onProjectClick,
|
||||
onRefresh = onRetry,
|
||||
@@ -6841,8 +6773,6 @@ private fun IssueFormMenuRow(
|
||||
items = options.map { option ->
|
||||
MulticaCupertinoActionSheetItem(
|
||||
title = option.label,
|
||||
badgeText = option.badgeText,
|
||||
badgeColor = option.badgeColor,
|
||||
contentDescription = "$contentDescription ${option.label}",
|
||||
selected = option.selected,
|
||||
onClick = option.onClick,
|
||||
@@ -6856,8 +6786,6 @@ private fun IssueFormMenuRow(
|
||||
|
||||
private data class IssueFormMenuOption(
|
||||
val label: String,
|
||||
val badgeText: String? = null,
|
||||
val badgeColor: Color = MulticaColors.Accent,
|
||||
val selected: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
@@ -6897,7 +6825,7 @@ private fun PilotIssueForm(
|
||||
projects = api.projects(session.workspace.id, 500, 0).items,
|
||||
members = api.members(session.workspace.id),
|
||||
agents = api.agents(session.workspace.id),
|
||||
squads = api.squads(session.workspace.id),
|
||||
squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6912,13 +6840,14 @@ private fun PilotIssueForm(
|
||||
)
|
||||
else -> {
|
||||
val options = loaded.getOrThrow()
|
||||
val assignees = pilotIssueFormAssigneeOptions(
|
||||
val assignees = Models.issueAssignees(
|
||||
true,
|
||||
if (zh) "未分配" else "Unassigned",
|
||||
session.user,
|
||||
options.members,
|
||||
options.agents,
|
||||
options.squads,
|
||||
)
|
||||
).map { PilotAssigneeOption(it.id, it.type, it.name) }
|
||||
var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) }
|
||||
var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) }
|
||||
var dueDate by remember(issue?.id) { mutableStateOf(issue?.dueDate.orEmpty()) }
|
||||
@@ -7018,7 +6947,6 @@ private fun PilotIssueForm(
|
||||
val status = Models.STATUS_VALUES[statusIndex]
|
||||
val priority = Models.PRIORITY_VALUES[priorityIndex]
|
||||
val selectedAssignee = assignees[assigneeIndex]
|
||||
val teamId = if (!editing && selectedAssignee.type == "squad") selectedAssignee.id else null
|
||||
val assignee = if (selectedAssignee.id == null) null else {
|
||||
Models.Assignee(selectedAssignee.id, selectedAssignee.type, selectedAssignee.name)
|
||||
}
|
||||
@@ -7028,7 +6956,7 @@ private fun PilotIssueForm(
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val saved = if (issue == null) {
|
||||
api.createIssue(titleText, description, session.workspace.id, projectId, teamId, status, priority, assignee, dueDateText, parentIssueId)
|
||||
api.createIssue(titleText, description, session.workspace.id, projectId, status, priority, assignee, dueDateText, parentIssueId)
|
||||
} else {
|
||||
api.updateIssue(issue, titleText, description, projectId, status, priority, assignee, dueDateText)
|
||||
}
|
||||
@@ -7265,8 +7193,6 @@ private fun PilotIssueForm(
|
||||
options = assignees.mapIndexed { index, assignee ->
|
||||
IssueFormMenuOption(
|
||||
label = assignee.name,
|
||||
badgeText = pilotMentionTypeLabel(assignee.type, zh),
|
||||
badgeColor = pilotMentionTypeColor(assignee.type),
|
||||
selected = assigneeIndex == index,
|
||||
onClick = { assigneeIndex = index },
|
||||
)
|
||||
@@ -9383,283 +9309,6 @@ private fun IssueAttachmentsWebPanel(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentPreview(
|
||||
api: ApiClient,
|
||||
attachment: Models.Attachment,
|
||||
zh: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val kind = remember(attachment.contentType, attachment.filename) {
|
||||
AttachmentPreview.kindFor(attachment.contentType, attachment.filename)
|
||||
}
|
||||
val target = remember(attachment.downloadUrl, attachment.url) {
|
||||
absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||
}
|
||||
var state by remember(attachment.id) { mutableStateOf<Result<String>?>(null) }
|
||||
var refresh by remember(attachment.id) { mutableIntStateOf(0) }
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
LaunchedEffect(attachment.id, target, refresh) {
|
||||
state = null
|
||||
state = withContext(Dispatchers.IO) {
|
||||
runCatching { api.downloadAttachmentText(target) }
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.semantics { contentDescription = "Attachment Preview Sheet ${attachment.id}" },
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.32f))
|
||||
.clickable(onClick = onBack),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.97f)
|
||||
.statusBarsPadding(),
|
||||
shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
|
||||
color = MulticaColors.Background,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 12.dp, top = 4.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(28.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(36.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(42.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MulticaColors.Border),
|
||||
)
|
||||
MulticaIconPillButton(
|
||||
icon = Icons.Outlined.Close,
|
||||
contentDescription = if (zh) "关闭附件预览" else "Close attachment preview",
|
||||
onClick = onBack,
|
||||
tone = MulticaButtonTone.Ghost,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
when {
|
||||
target.isBlank() -> MulticaErrorState(
|
||||
message = if (zh) "附件没有可预览链接" else "Attachment has no preview URL",
|
||||
onRetry = onBack,
|
||||
fullScreen = false,
|
||||
)
|
||||
state == null -> Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CupertinoActivityIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
state?.isFailure == true -> MulticaErrorState(
|
||||
message = "${if (zh) "附件加载失败" else "Attachment load failed"}\n${state?.exceptionOrNull()?.message.orEmpty()}",
|
||||
onRetry = { refresh++ },
|
||||
fullScreen = false,
|
||||
)
|
||||
else -> {
|
||||
val body = state?.getOrNull().orEmpty()
|
||||
when (kind) {
|
||||
AttachmentPreview.Kind.MARKDOWN -> AttachmentMarkdownPreview(body)
|
||||
AttachmentPreview.Kind.HTML -> AttachmentHtmlPreview(body, context)
|
||||
AttachmentPreview.Kind.XML -> AttachmentHtmlPreview(AttachmentPreview.xmlPreviewDocument(body), context)
|
||||
AttachmentPreview.Kind.DOWNLOAD -> MulticaErrorState(
|
||||
message = if (zh) "该附件类型不支持内置预览" else "This attachment type does not support built-in preview",
|
||||
onRetry = onBack,
|
||||
fullScreen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentMarkdownPreview(markdown: String) {
|
||||
SelectionContainer {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
factory = { context ->
|
||||
LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, 0, 0, 24)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.removeAllViews()
|
||||
MarkdownRenderer.render(
|
||||
view.context,
|
||||
view,
|
||||
markdown,
|
||||
MulticaColors.Text.toArgb(),
|
||||
MulticaColors.Muted.toArgb(),
|
||||
MulticaColors.Border.toArgb(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun ColumnScope.AttachmentHtmlPreview(html: String, context: Context) {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
factory = { viewContext ->
|
||||
WebView(viewContext).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = false
|
||||
settings.databaseEnabled = false
|
||||
settings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
settings.allowFileAccessFromFileURLs = false
|
||||
settings.allowUniversalAccessFromFileURLs = false
|
||||
clearCache(true)
|
||||
clearHistory()
|
||||
webViewClient = AttachmentPreviewWebViewClient(context)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
webView.loadDataWithBaseURL(AttachmentPreview.BASE_URL, html, "text/html", "UTF-8", null)
|
||||
},
|
||||
onRelease = { webView ->
|
||||
webView.stopLoading()
|
||||
webView.clearHistory()
|
||||
webView.clearCache(true)
|
||||
webView.destroy()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private class AttachmentPreviewWebViewClient(
|
||||
private val context: Context,
|
||||
) : WebViewClient() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
return handleNavigation(url)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
return handleNavigation(request.url?.toString().orEmpty())
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun handleNavigation(url: String): Boolean {
|
||||
if (AttachmentPreview.isWebViewInternalDocumentUrl(url)) return false
|
||||
if (AttachmentPreview.isLocalPreviewUrl(url)) return false
|
||||
openExternalUrl(context, url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentMarkdownPreviewFixture() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MulticaColors.Background)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PilotPageHeader(title = "Markdown Attachment Preview")
|
||||
AttachmentMarkdownPreview(
|
||||
"""
|
||||
# Markdown Attachment
|
||||
|
||||
This attachment renders **inside Android** with the existing Markdown renderer.
|
||||
|
||||
| Feature | Status |
|
||||
| --- | --- |
|
||||
| Tables | Ready |
|
||||
| Links | [Multica](https://app.multica.ai) |
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PilotAttachmentHtmlPreviewFixture() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MulticaColors.Background)
|
||||
.statusBarsPadding()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PilotPageHeader(title = "HTML Attachment Preview")
|
||||
AttachmentHtmlPreview(
|
||||
"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font: 16px -apple-system, BlinkMacSystemFont, sans-serif; padding: 16px; color: #111; }
|
||||
button { border: 1px solid #d1d5db; border-radius: 999px; padding: 10px 14px; background: white; }
|
||||
button.active { background: #0A58FF; color: white; }
|
||||
.panel { display: none; margin-top: 16px; padding: 16px; border: 1px solid #e5e7eb; border-radius: 16px; }
|
||||
.panel.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HTML Attachment</h1>
|
||||
<button id="tab-one" class="active" onclick="showTab('one')">Tab One</button>
|
||||
<button id="tab-two" onclick="showTab('two')">Tab Two</button>
|
||||
<a href="#local-anchor" id="anchor-link">Jump to anchor</a>
|
||||
<a href="https://example.com/" id="external-link">External link</a>
|
||||
<section id="one" class="panel active">First tab content</section>
|
||||
<section id="two" class="panel">Second tab content</section>
|
||||
<p id="local-anchor">Local anchor target</p>
|
||||
<script>
|
||||
function showTab(id) {
|
||||
document.querySelectorAll('button').forEach(function(button) { button.classList.remove('active'); });
|
||||
document.querySelectorAll('.panel').forEach(function(panel) { panel.classList.remove('active'); });
|
||||
document.getElementById('tab-' + id).classList.add('active');
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
LocalContext.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldCollapseIssueComment(content: String): Boolean {
|
||||
if (content.contains("```")) return false
|
||||
if (content.lines().any { it.trim().startsWith("|") && it.trim().endsWith("|") }) return false
|
||||
@@ -9719,7 +9368,6 @@ private fun PilotIssueDetail(
|
||||
var uploadingAttachment by remember(issueId) { mutableStateOf(false) }
|
||||
var deletingAttachmentId by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
var attachmentMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
var previewAttachment by remember(issueId) { mutableStateOf<Models.Attachment?>(null) }
|
||||
var confirmingDeleteIssue by remember(issueId) { mutableStateOf(false) }
|
||||
var deletingIssue by remember(issueId) { mutableStateOf(false) }
|
||||
var issueMutationMessage by remember(issueId) { mutableStateOf<String?>(null) }
|
||||
@@ -9756,6 +9404,7 @@ private fun PilotIssueDetail(
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LaunchedEffect(issueId, workspaceId, highlightCommentId, detailRefresh) {
|
||||
state = null
|
||||
val coreResult = withContext(Dispatchers.IO) {
|
||||
@@ -10001,15 +9650,11 @@ private fun PilotIssueDetail(
|
||||
}
|
||||
|
||||
fun openAttachment(attachment: Models.Attachment) {
|
||||
val target = absoluteAttachmentUrl(clean(attachment.downloadUrl).ifBlank { clean(attachment.url) })
|
||||
val target = clean(attachment.downloadUrl).ifBlank { clean(attachment.url) }
|
||||
if (target.isBlank()) {
|
||||
attachmentMessage = if (zh) "附件没有可打开的链接" else "Attachment has no openable URL"
|
||||
return
|
||||
}
|
||||
if (AttachmentPreview.kindFor(attachment.contentType, attachment.filename) != AttachmentPreview.Kind.DOWNLOAD) {
|
||||
previewAttachment = attachment
|
||||
return
|
||||
}
|
||||
val result = runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(target))
|
||||
@@ -11115,14 +10760,6 @@ if (replyingThread != null) {
|
||||
},
|
||||
)
|
||||
}
|
||||
previewAttachment?.let { attachment ->
|
||||
PilotAttachmentPreview(
|
||||
api = api,
|
||||
attachment = attachment,
|
||||
zh = zh,
|
||||
onBack = { previewAttachment = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11196,7 +10833,34 @@ private fun IssueCommentReplyBox(
|
||||
val focusManager = LocalFocusManager.current
|
||||
val replyActionModifier = Modifier.size(42.dp)
|
||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||
val query = mentionQuery.trim().lowercase()
|
||||
val agentItems = agents
|
||||
.filter { it.archivedAt.isBlank() }
|
||||
.filter { agent ->
|
||||
query.isBlank() ||
|
||||
agent.name.lowercase().contains(query) ||
|
||||
agent.description.lowercase().contains(query) ||
|
||||
agent.id.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
|
||||
val memberItems = members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.displayName.lowercase().contains(query) ||
|
||||
member.email.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.map { Triple(it.id, it.displayName, "member") }
|
||||
val squadItems = squads
|
||||
.filter { squad ->
|
||||
query.isBlank() ||
|
||||
squad.name.lowercase().contains(query) ||
|
||||
squad.description.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
|
||||
agentItems + memberItems + squadItems
|
||||
}
|
||||
LaunchedEffect(parentId) {
|
||||
delay(180)
|
||||
@@ -11338,15 +11002,12 @@ Column(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
mentionItems.forEach { item ->
|
||||
mentionItems.forEach { (id, name, type) ->
|
||||
IssueMentionUnifiedRow(
|
||||
name = item.name,
|
||||
mentionType = item.type.orEmpty(),
|
||||
zh = zh,
|
||||
contentDescription = "Issue Comment Reply Mention ${item.type} Row ${item.id}",
|
||||
name = name,
|
||||
mentionType = type,
|
||||
contentDescription = "Issue Comment Reply Mention $type Row $id",
|
||||
onClick = {
|
||||
val name = item.name
|
||||
val id = item.id.orEmpty()
|
||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
@@ -11437,7 +11098,6 @@ private fun IssueMentionAgentWebRow(
|
||||
private fun IssueMentionUnifiedRow(
|
||||
name: String,
|
||||
mentionType: String,
|
||||
zh: Boolean,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
@@ -11446,13 +11106,21 @@ private fun IssueMentionUnifiedRow(
|
||||
"squad" -> name.take(1).uppercase().ifBlank { "S" }
|
||||
else -> name.take(1).uppercase().ifBlank { "A" }
|
||||
}
|
||||
val iconColor = pilotMentionTypeColor(mentionType)
|
||||
val iconColor = when (mentionType) {
|
||||
"member" -> MulticaColors.Text
|
||||
"squad" -> MulticaColors.Accent
|
||||
else -> MulticaColors.Accent
|
||||
}
|
||||
val iconBg = when (mentionType) {
|
||||
"member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f)
|
||||
"squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f)
|
||||
else -> MulticaColors.AccentSoft.copy(alpha = 0.24f)
|
||||
}
|
||||
val typeLabel = pilotMentionTypeLabel(mentionType, zh)
|
||||
val typeLabel = when (mentionType) {
|
||||
"member" -> "member"
|
||||
"squad" -> "squad"
|
||||
else -> "agent"
|
||||
}
|
||||
val mentionMarkdown = when (mentionType) {
|
||||
"member" -> AgentMentionMarkdown.memberMarkdown(name, "")
|
||||
"squad" -> AgentMentionMarkdown.squadMarkdown(name, "")
|
||||
@@ -11549,7 +11217,34 @@ private fun IssueCommentInputBar(
|
||||
configuration.screenHeightDp,
|
||||
).dp
|
||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||
pilotMentionOptions(false, "", null, members, agents.filter { it.archivedAt.isBlank() }, squads, mentionQuery)
|
||||
val query = mentionQuery.trim().lowercase()
|
||||
val agentItems = agents
|
||||
.filter { it.archivedAt.isBlank() }
|
||||
.filter { agent ->
|
||||
query.isBlank() ||
|
||||
agent.name.lowercase().contains(query) ||
|
||||
agent.description.lowercase().contains(query) ||
|
||||
agent.id.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") }
|
||||
val memberItems = members
|
||||
.filter { member ->
|
||||
query.isBlank() ||
|
||||
member.displayName.lowercase().contains(query) ||
|
||||
member.email.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.displayName.lowercase() }
|
||||
.map { Triple(it.id, it.displayName, "member") }
|
||||
val squadItems = squads
|
||||
.filter { squad ->
|
||||
query.isBlank() ||
|
||||
squad.name.lowercase().contains(query) ||
|
||||
squad.description.lowercase().contains(query)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
.map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") }
|
||||
agentItems + memberItems + squadItems
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -11715,15 +11410,12 @@ Column(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
mentionItems.forEach { item ->
|
||||
mentionItems.forEach { (id, name, type) ->
|
||||
IssueMentionUnifiedRow(
|
||||
name = item.name,
|
||||
mentionType = item.type.orEmpty(),
|
||||
zh = zh,
|
||||
contentDescription = "Issue Comment Mention ${item.type} Row ${item.id}",
|
||||
name = name,
|
||||
mentionType = type,
|
||||
contentDescription = "Issue Comment Mention $type Row $id",
|
||||
onClick = {
|
||||
val name = item.name
|
||||
val id = item.id.orEmpty()
|
||||
val insertion = AgentMentionMarkdown.displayText(name, id)
|
||||
val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " "
|
||||
onDraftChange(draft + separator + insertion + " ")
|
||||
@@ -12364,15 +12056,6 @@ private fun absoluteMarkdownImageUrl(url: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun absoluteAttachmentUrl(url: String): String {
|
||||
val value = clean(url)
|
||||
return if (value.startsWith("/")) {
|
||||
BuildConfig.MULTICA_API_BASE_URL.trimEnd('/') + value
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
private fun openExternalUrl(context: Context, url: String) {
|
||||
val target = clean(url)
|
||||
if (target.isBlank()) return
|
||||
@@ -13295,7 +12978,6 @@ private fun PilotProjects(
|
||||
workspaceId: String,
|
||||
workspace: Models.Workspace,
|
||||
projects: List<Models.Project>,
|
||||
currentUser: Models.User?,
|
||||
zh: Boolean,
|
||||
onProjectClick: (Models.Project) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
@@ -13318,7 +13000,6 @@ private fun PilotProjects(
|
||||
var resourceUrlsText by remember(workspaceId, editingProject?.id, formOpen) { mutableStateOf("") }
|
||||
var projectMembers by remember(workspaceId) { mutableStateOf<List<Models.Member>>(emptyList()) }
|
||||
var projectAgents by remember(workspaceId) { mutableStateOf<List<Models.Agent>>(emptyList()) }
|
||||
var projectSquads by remember(workspaceId) { mutableStateOf<List<Models.Squad>>(emptyList()) }
|
||||
var loadingProjectOptions by remember(workspaceId) { mutableStateOf(false) }
|
||||
var saving by remember(workspaceId) { mutableStateOf(false) }
|
||||
var pendingDelete by remember(workspaceId) { mutableStateOf<Models.Project?>(null) }
|
||||
@@ -13418,7 +13099,7 @@ private fun PilotProjects(
|
||||
}
|
||||
|
||||
LaunchedEffect(formOpen, workspaceId) {
|
||||
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || projectSquads.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
||||
if (!formOpen || projectMembers.isNotEmpty() || projectAgents.isNotEmpty() || loadingProjectOptions) return@LaunchedEffect
|
||||
loadingProjectOptions = true
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { api.members(workspaceId) to api.agents(workspaceId) }
|
||||
@@ -13428,26 +13109,15 @@ private fun PilotProjects(
|
||||
}.onFailure {
|
||||
resultText = "${if (zh) "项目选项加载失败" else "Project options failed"}: ${it.message ?: it.toString()}"
|
||||
}
|
||||
val squads = withContext(Dispatchers.IO) { runCatching { api.squads(workspaceId) }.getOrDefault(emptyList()) }
|
||||
projectSquads = squads
|
||||
loadingProjectOptions = false
|
||||
}
|
||||
|
||||
val projectLeadOptions = remember(currentUser, projectMembers, projectAgents, projectSquads, zh) {
|
||||
pilotIssueFormAssigneeOptions(
|
||||
if (zh) "无负责人" else "No Lead",
|
||||
currentUser,
|
||||
projectMembers,
|
||||
projectAgents,
|
||||
projectSquads,
|
||||
)
|
||||
}
|
||||
|
||||
val selectedLeadLabel = remember(leadType, leadId, projectLeadOptions, zh) {
|
||||
projectLeadOptions.firstOrNull { option ->
|
||||
option.id == leadId && (option.type == leadType || leadType.isBlank())
|
||||
}?.name ?: when (leadType) {
|
||||
"squad", "agent", "member" -> Models.shortId(leadId)
|
||||
val selectedLeadLabel = remember(leadType, leadId, projectMembers, projectAgents, zh) {
|
||||
when (leadType) {
|
||||
"member" -> projectMembers.firstOrNull { it.userId == leadId || it.id == leadId }?.let { member ->
|
||||
member.displayName.ifBlank { member.email }
|
||||
} ?: Models.shortId(leadId)
|
||||
"agent" -> projectAgents.firstOrNull { it.id == leadId }?.name ?: Models.shortId(leadId)
|
||||
else -> if (zh) "无负责人" else "No Lead"
|
||||
}
|
||||
}
|
||||
@@ -13597,24 +13267,39 @@ private fun PilotProjects(
|
||||
eyebrow = if (zh) "负责人" else "Lead",
|
||||
title = selectedLeadLabel,
|
||||
subtitle = if (loadingProjectOptions) {
|
||||
if (zh) "正在加载人、Agent 和小队" else "Loading people, agents, and squads"
|
||||
if (zh) "正在加载成员和 Agent" else "Loading members and agents"
|
||||
} else {
|
||||
null
|
||||
},
|
||||
contentDescription = "Project Lead Picker",
|
||||
options = projectLeadOptions.map { lead ->
|
||||
options = listOf(
|
||||
IssueFormMenuOption(
|
||||
label = lead.name,
|
||||
badgeText = pilotMentionTypeLabel(lead.type, zh),
|
||||
badgeColor = pilotMentionTypeColor(lead.type),
|
||||
selected = if (lead.id == null) {
|
||||
leadType.isBlank() || leadId.isBlank()
|
||||
} else {
|
||||
leadType == lead.type && leadId == lead.id
|
||||
},
|
||||
label = if (zh) "无负责人" else "No Lead",
|
||||
selected = leadType.isBlank() || leadId.isBlank(),
|
||||
onClick = {
|
||||
leadType = lead.type.orEmpty()
|
||||
leadId = lead.id.orEmpty()
|
||||
leadType = ""
|
||||
leadId = ""
|
||||
},
|
||||
)
|
||||
) + projectMembers.map { member ->
|
||||
val assigneeId = member.userId.ifBlank { member.id }
|
||||
IssueFormMenuOption(
|
||||
label = listOf(member.displayName.ifBlank { member.email }, if (zh) "成员" else "Member")
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
selected = leadType == "member" && (leadId == assigneeId || leadId == member.id),
|
||||
onClick = {
|
||||
leadType = "member"
|
||||
leadId = assigneeId
|
||||
},
|
||||
)
|
||||
} + projectAgents.map { agent ->
|
||||
IssueFormMenuOption(
|
||||
label = listOf(agent.name, "Agent").filter { it.isNotBlank() }.joinToString(" · "),
|
||||
selected = leadType == "agent" && leadId == agent.id,
|
||||
onClick = {
|
||||
leadType = "agent"
|
||||
leadId = agent.id
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package ai.multica.app;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class IssuePayloads {
|
||||
private IssuePayloads() {
|
||||
}
|
||||
|
||||
static JSONObject createIssue(String title, String description, String workspaceId, String projectId,
|
||||
String teamId, String status, String priority, Models.Assignee assignee,
|
||||
String dueDate, String parentIssueId) throws Exception {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("title", title);
|
||||
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
|
||||
body.put("workspace_id", workspaceId);
|
||||
if (projectId != null) body.put("project_id", projectId);
|
||||
if (teamId != null && !teamId.trim().isEmpty()) body.put("team_id", teamId.trim());
|
||||
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
|
||||
if (status != null) body.put("status", status);
|
||||
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 body;
|
||||
}
|
||||
}
|
||||
@@ -810,7 +810,6 @@ public final class MainActivity extends Activity {
|
||||
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
|
||||
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
|
||||
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
|
||||
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
|
||||
if (demoMode) {
|
||||
upsertDemoIssue(
|
||||
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
|
||||
@@ -829,7 +828,7 @@ public final class MainActivity extends Activity {
|
||||
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
|
||||
}
|
||||
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()));
|
||||
}).show();
|
||||
}
|
||||
@@ -1862,9 +1861,7 @@ public final class MainActivity extends Activity {
|
||||
private List<Models.Assignee> assignees(boolean includeEmpty) {
|
||||
if (memberCache.isEmpty()) memberCache = safeMembers();
|
||||
if (agentCache.isEmpty()) agentCache = safeAgents();
|
||||
if (squadCache.isEmpty()) squadCache = safeSquads();
|
||||
if (includeEmpty) return Models.issueFormAssigneeOptions(t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
return Models.issueAssignees(false, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
|
||||
}
|
||||
|
||||
private String assigneeName(String id, String type) {
|
||||
|
||||
@@ -230,31 +230,6 @@ final class Models {
|
||||
return list;
|
||||
}
|
||||
|
||||
static List<Assignee> issueFormAssigneeOptions(String emptyLabel, User currentUser,
|
||||
List<Member> members, List<Agent> agents, List<Squad> squads) {
|
||||
List<Assignee> list = new ArrayList<>();
|
||||
list.add(new Assignee(null, null, emptyLabel));
|
||||
for (Squad squad : squads) {
|
||||
if (squad.id == null || squad.id.isEmpty()) continue;
|
||||
list.add(new Assignee(squad.id, "squad", squad.name));
|
||||
}
|
||||
for (Agent agent : agents) {
|
||||
if (agent.id == null || agent.id.isEmpty()) continue;
|
||||
list.add(new Assignee(agent.id, "agent", agent.name));
|
||||
}
|
||||
List<String> seenMemberIds = new ArrayList<>();
|
||||
if (currentUser != null && currentUser.id != null && !currentUser.id.isEmpty()) {
|
||||
list.add(new Assignee(currentUser.id, "member", currentUser.name));
|
||||
seenMemberIds.add(currentUser.id);
|
||||
}
|
||||
for (Member member : members) {
|
||||
if (member.id == null || member.id.isEmpty() || seenMemberIds.contains(member.id)) continue;
|
||||
list.add(new Assignee(member.id, "member", member.displayName));
|
||||
seenMemberIds.add(member.id);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static final class Page<T> {
|
||||
final List<T> items;
|
||||
final boolean hasMore;
|
||||
|
||||
@@ -1021,8 +1021,6 @@ data class MulticaCupertinoActionSheetItem(
|
||||
val title: String,
|
||||
val subtitle: String? = null,
|
||||
val icon: ImageVector? = null,
|
||||
val badgeText: String? = null,
|
||||
val badgeColor: Color = MulticaColors.Accent,
|
||||
val contentDescription: String? = null,
|
||||
val selected: Boolean = false,
|
||||
val destructive: Boolean = false,
|
||||
@@ -1058,40 +1056,78 @@ fun MulticaCupertinoActionSheet(
|
||||
)
|
||||
},
|
||||
message = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (!message.isNullOrBlank()) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||
color = MulticaColors.TextTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 420.dp),
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
val item = items[index]
|
||||
MulticaCupertinoActionSheetItemContent(
|
||||
item = item,
|
||||
onClick = {
|
||||
if (!item.enabled) return@MulticaCupertinoActionSheetItemContent
|
||||
performMulticaTapFeedback(haptic)
|
||||
item.onClick()
|
||||
onDismissRequest()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!message.isNullOrBlank()) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp),
|
||||
color = MulticaColors.TextTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
},
|
||||
buttons = {
|
||||
items.forEach { item ->
|
||||
action(
|
||||
onClick = {
|
||||
performMulticaTapFeedback(haptic)
|
||||
item.onClick()
|
||||
onDismissRequest()
|
||||
},
|
||||
style = if (item.destructive) AlertActionStyle.Destructive else AlertActionStyle.Default,
|
||||
enabled = item.enabled,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 19.sp,
|
||||
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
),
|
||||
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!item.subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = item.subtitle,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
||||
color = MulticaColors.TextTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (item.selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MulticaColors.Accent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cancelText.isNullOrBlank()) {
|
||||
action(
|
||||
onClick = {
|
||||
@@ -1112,83 +1148,6 @@ fun MulticaCupertinoActionSheet(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MulticaCupertinoActionSheetItemContent(
|
||||
item: MulticaCupertinoActionSheetItem,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = item.enabled, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 11.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = "Multica Cupertino Action Sheet ${item.contentDescription ?: item.title}"
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (item.destructive) MulticaColors.Danger else MulticaColors.TextTertiary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
if (!item.badgeText.isNullOrBlank()) {
|
||||
Text(
|
||||
text = item.badgeText,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(item.badgeColor.copy(alpha = 0.16f))
|
||||
.border(1.dp, item.badgeColor.copy(alpha = 0.32f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = item.badgeColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 19.sp,
|
||||
fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
),
|
||||
color = if (item.destructive) MulticaColors.Danger else MulticaColors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!item.subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = item.subtitle,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp, lineHeight = 13.sp),
|
||||
color = MulticaColors.TextTertiary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (item.selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MulticaColors.Accent,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalCupertinoApi::class)
|
||||
fun MulticaCupertinoAlertDialog(
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Multi-Casual Android</title>
|
||||
<link rel="canonical" href="https://park0er.github.io/multi-casual-android/">
|
||||
<meta name="description" content="Main landing page for Multi-Casual Android, an independent Android companion app for Multica users.">
|
||||
<style>body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#fff;color:#111}a{color:#2563eb}main{max-width:960px;margin:0 auto;padding:48px 24px}h1{font-size:2rem}p{line-height:1.6;margin:12px 0}footer{margin-top:64px;color:#555}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Multi-Casual Android</h1>
|
||||
<p>Main site is published at <a href="https://multi-casual-android.pages.dev">https://multi-casual-android.pages.dev</a> and <a href="https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/">https://multi-casual-china-8temdtzs.zh-cn.edgeone.cool/</a>.</p>
|
||||
<p>You can also install the signed APK directly from <a href="https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk">GitHub Releases</a>.</p>
|
||||
</main>
|
||||
<footer>
|
||||
<p>Site placeholder published from the public Android source repository.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
output/
|
||||
.playwright-cli/
|
||||
*.tsbuildinfo
|
||||
@@ -1,14 +0,0 @@
|
||||
<!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="Multi-Casual Android is an independent Android companion app for Multica users." />
|
||||
<link rel="icon" href="./favicon.png" type="image/png" />
|
||||
<title>Multi-Casual Android</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"dev:62702": "vite --host 127.0.0.1 --port 62702",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1,685 +0,0 @@
|
||||
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.multicasual.app",
|
||||
sha256: "35705dcc46fdc5fd9d0d718f50844f94bd55e49e236e4ca8c8b7b2e4f89b6c2f",
|
||||
githubUrl: "https://github.com/park0er/multi-casual-android",
|
||||
releasesUrl: "https://github.com/park0er/multi-casual-android/releases/download/v0.1.4/Multi-Casual-Android-v0.1.4.apk",
|
||||
iosUrl: "https://github.com/park0er/multi-casual-ios-app",
|
||||
multicaUrl: "https://multica.ai",
|
||||
apiUrl: "https://api.multica.ai",
|
||||
};
|
||||
|
||||
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; github: string; multica: string; ios: string };
|
||||
relationship: { kicker: string; title: string; body1: string; body2: string };
|
||||
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; star: string };
|
||||
trust: { kicker: string; items: string[] };
|
||||
footer: { tagline: string; multica: string; github: string; ios: string; download: string };
|
||||
}> = {
|
||||
en: {
|
||||
nav: { brand: "Multi-Casual Android", download: "Download", trust: "Trust", language: "中文" },
|
||||
hero: {
|
||||
eyebrow: "Independent companion app · Built for Multica workflows",
|
||||
title: "Keep your Multica workspace moving from Android.",
|
||||
subtitle: "Check inbox updates, browse issues, read full context, reply in threads, and keep agent workspace assets close — an independent Android companion app for Multica users.",
|
||||
download: "Download APK",
|
||||
github: "GitHub",
|
||||
multica: "Visit Multica",
|
||||
ios: "iOS App",
|
||||
},
|
||||
relationship: {
|
||||
kicker: "Relationship to Multica",
|
||||
title: "Respectful, explicit, and independent.",
|
||||
body1: "Multi-Casual Android is an independent companion app for Multica users. It connects to Multica service APIs for compatibility, but it is not maintained, endorsed, or published by the Multica project maintainers.",
|
||||
body2: "This project was originally explored as a contribution path with the Multica community. The Multica maintainers later indicated that their own app direction would be led upstream, so this Android client is maintained independently, following that boundary while staying respectful of the original project.",
|
||||
},
|
||||
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: "GitHub APK", title: "Install from GitHub Releases.", package: "Package", api: "Service API", sha: "APK SHA256", latest: "Download APK", source: "Source code", star: "Like the app? Give us a star on GitHub ⭐ — it means a lot to the maintainers." },
|
||||
trust: {
|
||||
kicker: "Trust and privacy",
|
||||
items: [
|
||||
"Community-maintained independent project; not affiliated with or endorsed by Multica maintainers.",
|
||||
"Published as its own Android app for Multica users, with explicit attribution and product boundaries.",
|
||||
"Open source under GPLv3 license — inspect, fork, or contribute.",
|
||||
"No chat content, file content, email address, tokens, or precise location are collected.",
|
||||
],
|
||||
},
|
||||
footer: { tagline: "Independent Android companion app for Multica users. GitHub-distributed APK.", multica: "Multica website", github: "GitHub", ios: "iOS App", download: "Download APK" },
|
||||
},
|
||||
zh: {
|
||||
nav: { brand: "Multi-Casual Android", download: "下载", trust: "信任说明", language: "English" },
|
||||
hero: {
|
||||
eyebrow: "独立 companion app · 面向 Multica 工作流",
|
||||
title: "在 Android 上跟进你的 Multica 工作现场。",
|
||||
subtitle: "查看通知、浏览 issues、阅读完整上下文、回复评论、@ 对方 —— 面向 Multica 用户的独立 Android companion app。",
|
||||
download: "下载 APK",
|
||||
github: "GitHub",
|
||||
multica: "访问 Multica",
|
||||
ios: "iOS App",
|
||||
},
|
||||
relationship: {
|
||||
kicker: "与 Multica 的关系",
|
||||
title: "礼貌、清楚、保持独立。",
|
||||
body1: "Multi-Casual Android 是面向 Multica 用户的独立 companion app。它为了兼容现有工作流而连接 Multica 服务 API,但并不由 Multica 项目维护者维护、背书或发布。",
|
||||
body2: "这个项目最初是作为向 Multica 社区贡献的路径来探索的。由于 Multica maintainers 后续计划推进他们自己主导的 app 体系,因此这套 Android 代码体系没有直接并入上游。我们尊重 maintainers 的边界建议,将 Multi-Casual Android 作为独立 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: "GitHub APK", title: "从 GitHub Releases 安装。", package: "Package", api: "Service API", sha: "APK SHA256", latest: "下载APK包", source: "源代码", star: "喜欢我们的 app?请在 GitHub 上给项目点个 Star ⭐,这是对我们作者最大的支持。" },
|
||||
trust: {
|
||||
kicker: "信任与隐私",
|
||||
items: [
|
||||
"社区维护的独立项目;与 Multica maintainers 无隶属关系,无背书。",
|
||||
"作为面向 Multica 用户的独立 Android app 发布,明确标注产品边界。",
|
||||
"GPLv3 开源许可证 —— 可以查看、fork 或贡献。",
|
||||
"不收集聊天内容、文件内容、邮箱、tokens 或精确位置信息。",
|
||||
],
|
||||
},
|
||||
footer: { tagline: "面向 Multica 用户的独立 Android companion app。GitHub 分发 APK。", multica: "Multica 官网", github: "GitHub", 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="Multi-Casual" 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.githubUrl} 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="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>{t.hero.github}</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 */}
|
||||
<motion.section id="relationship" className="relationship section-pad" {...reveal(reduced)}>
|
||||
<div className="section-kicker">{t.relationship.kicker}</div>
|
||||
<div className="relationship-card">
|
||||
<h2>{t.relationship.title}</h2>
|
||||
<p>{t.relationship.body1}</p>
|
||||
<p>{t.relationship.body2}</p>
|
||||
<p>
|
||||
<a href="https://github.com/multica-ai/multica/issues/2285#issuecomment-4414513596" target="_blank" rel="noopener noreferrer">
|
||||
View the discussion on GitHub →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* 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 Multi-Casual-Android-v0.1.4.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.githubUrl}>{t.download.source}</a>
|
||||
</div>
|
||||
<p className="star-hint">
|
||||
{lang === "en"
|
||||
? <>Like the app? Give us a star on <a href={release.githubUrl} target="_blank" rel="noopener">GitHub</a> ⭐ — it means a lot to the maintainers.</>
|
||||
: <>喜欢我们的 app?请在 <a href={release.githubUrl} target="_blank" rel="noopener">GitHub</a> 上给项目点个 Star ⭐,这是对我们作者最大的支持。</>
|
||||
}
|
||||
</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>Multi-Casual Android</strong>
|
||||
<p>{t.footer.tagline}</p>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<a href={release.multicaUrl}>{t.footer.multica}</a>
|
||||
<a href={release.githubUrl}>{t.footer.github}</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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
);
|
||||
@@ -1,971 +0,0 @@
|
||||
: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; }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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',
|
||||
},
|
||||
}));
|
||||