Compare commits

...

5 Commits

Author SHA1 Message Date
ZhenYi
18917b6de1 feat(room): 修改 AI use_exact 默认值为 true
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
- room/src/ai.rs: use_exact 默认值从 false 改为 true
- 新增 migration: m20260428_000002_default_use_exact_true
2026-04-28 20:00:12 +08:00
ZhenYi
aab9f0dbf1 feat(frontend): 替换全屏loading动画为SVG逐帧绘制动画
- 新增 loading-animation.tsx 组件,从 public/load.html 转换
- 动画特性: SVG路径逐条绘制 + 渐变填充,最少展示1.5秒
- 替换 homepage/layout.tsx, workspace/redirect.tsx, protected-route.tsx 中的全屏加载
- 修复 sidebar-user.tsx 中 Invitations 按钮在缩回状态下的居中问题
2026-04-28 19:59:31 +08:00
ZhenYi
4571d4d042 fix(service): 修复扣费结果类型处理
- service/agent/billing.rs: 适配新的 BillingResult 枚举类型
- 将 InsufficientBalance 错误转换为 AppError::BadRequest
2026-04-28 19:59:17 +08:00
ZhenYi
c6bb72682b fix(agent): 修复扣费链路并实现级联扣费策略
- billing.rs: 修复参数传递 (model_id -> version_id)
- billing.rs: 新增 BillingResult 枚举支持 InsufficientBalance 错误
- billing.rs: 实现级联扣费 (优先 project 余额,不足时 fallback 到 workspace)
- billing.rs: 余额不足时创建系统消息并持久化
- chat/service.rs: 捕获 InsufficientBalance 错误并调用 create_system_message
- client/mod.rs: 超时时间从 60s 改为 120s
2026-04-28 19:59:06 +08:00
ZhenYi
13523762aa fix(fctool): 修复 git tools 中的类型不匹配问题
- blob.rs: 修复 resolve_oid 返回 commit OID 而非 blob OID 的问题
- tree.rs: 修复 git_tree_ls_exec 直接传递 commit OID 给 tree_list 的问题
- 所有修改使类型合约与 git domain API 匹配
2026-04-28 19:58:52 +08:00
18 changed files with 640 additions and 173 deletions

View File

@ -23,20 +23,28 @@ pub struct BillingRecord {
pub output_tokens: i64,
}
/// Record AI usage for a project.
/// Extended result that includes insufficient balance flag for system message creation.
#[derive(Debug)]
pub enum BillingResult {
Success(BillingRecord),
InsufficientBalance { message: String },
}
/// Record AI usage for a project with cascading billing.
///
/// If the project belongs to a workspace, the cost is deducted from the
/// workspace's shared quota. Otherwise it is deducted from the project's own
/// billing balance.
/// Billing strategy:
/// 1. Try to deduct from project balance first
/// 2. If insufficient, fallback to workspace balance (if project belongs to workspace)
/// 3. If both insufficient or no workspace, return InsufficientBalance error with room_id
///
/// Returns an error if there is insufficient balance.
/// Returns BillingError::InsufficientBalance with room_id for system message creation.
pub async fn record_ai_usage(
db: &AppDatabase,
project_uid: Uuid,
model_id: Uuid,
input_tokens: i64,
output_tokens: i64,
) -> Result<BillingRecord, AgentError> {
) -> Result<BillingResult, AgentError> {
// 1. Look up the active price for this model.
let pricing = model_pricing::Entity::find()
.filter(model_pricing::Column::ModelVersionId.eq(model_id))
@ -68,106 +76,27 @@ pub async fn record_ai_usage(
let currency = pricing.currency.clone();
// 3. Determine whether to bill the project or its workspace.
// 3. Cascading billing: project balance first, then workspace if insufficient.
let proj = project::Entity::find_by_id(project_uid)
.one(db)
.await?
.ok_or_else(|| AgentError::Internal("Project not found".into()))?;
if let Some(workspace_id) = proj.workspace_id {
// ── Workspace-shared quota ──────────────────────────────────
let txn = db.begin().await?;
let txn = db.begin().await?;
// SELECT FOR UPDATE to prevent race conditions
let current = workspace_billing::Entity::find_by_id(workspace_id)
.lock_exclusive()
.one(&txn)
.await?
.ok_or_else(|| AgentError::Internal("Workspace billing account not found".into()))?;
// Always check project balance first
let project_billing = project_billing::Entity::find_by_id(project_uid)
.lock_exclusive()
.one(&txn)
.await?
.ok_or_else(|| AgentError::Internal("Project billing account not found".into()))?;
// Validate balance before any modifications
if current.balance < total_cost {
txn.rollback().await?;
return Err(AgentError::Internal(format!(
"Insufficient workspace billing balance. Required: {:.4} {}, Available: {:.4} {}",
total_cost, currency, current.balance, currency
)));
}
let now = chrono::Utc::now();
if project_billing.balance >= total_cost {
// ── Project has sufficient balance ──────────────────────────
let amount_dec = -total_cost;
let now = chrono::Utc::now();
// Insert workspace billing history AFTER validation
workspace_billing_history::ActiveModel {
uid: Set(Uuid::new_v4()),
workspace_id: Set(workspace_id),
user_id: Set(Some(proj.created_by)),
amount: Set(amount_dec),
currency: Set(currency.clone()),
reason: Set(format!("ai_usage:{}", project_uid)),
extra: Set(Some(serde_json::json!({
"project_id": project_uid.to_string(),
"model_id": model_id.to_string(),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}))),
created_at: Set(now),
}
.insert(&txn)
.await?;
// Deduct from workspace balance
let new_balance = current.balance - total_cost;
let mut updated: workspace_billing::ActiveModel = current.into();
updated.balance = Set(new_balance);
updated.updated_at = Set(now);
updated.update(&txn).await?;
txn.commit().await?;
let cost_f64 = total_cost.to_string().parse().unwrap_or(0.0);
tracing::info!(
project_id = %project_uid,
model_id = %model_id,
input_tokens = input_tokens,
output_tokens = output_tokens,
cost = %cost_f64,
currency = %currency,
workspace_id = %workspace_id.to_string(),
"ai_usage_recorded"
);
Ok(BillingRecord {
cost: cost_f64,
currency,
input_tokens,
output_tokens,
})
} else {
// ── Project-owned quota ─────────────────────────────────────
let txn = db.begin().await?;
// SELECT FOR UPDATE to prevent race conditions
let current = project_billing::Entity::find_by_id(project_uid)
.lock_exclusive()
.one(&txn)
.await?
.ok_or_else(|| AgentError::Internal("Project billing account not found".into()))?;
// Validate balance before any modifications
if current.balance < total_cost {
txn.rollback().await?;
return Err(AgentError::Internal(format!(
"Insufficient billing balance. Required: {:.4} {}, Available: {:.4} {}",
total_cost, currency, current.balance, currency
)));
}
let amount_dec = -total_cost;
let now = chrono::Utc::now();
// Insert project billing history AFTER validation
project_billing_history::ActiveModel {
uid: Set(Uuid::new_v4()),
project: Set(project_uid),
@ -186,9 +115,8 @@ pub async fn record_ai_usage(
.insert(&txn)
.await?;
// Deduct from project balance
let new_balance = current.balance - total_cost;
let mut updated: project_billing::ActiveModel = current.into();
let new_balance = project_billing.balance - total_cost;
let mut updated: project_billing::ActiveModel = project_billing.into();
updated.balance = Set(new_balance);
updated.update(&txn).await?;
@ -203,14 +131,93 @@ pub async fn record_ai_usage(
output_tokens = output_tokens,
cost = %cost_f64,
currency = %currency,
source = "project",
"ai_usage_recorded"
);
Ok(BillingRecord {
Ok(BillingResult::Success(BillingRecord {
cost: cost_f64,
currency,
input_tokens,
output_tokens,
}))
} else if let Some(workspace_id) = proj.workspace_id {
// ── Project insufficient, fallback to workspace ─────────────
let workspace_billing = workspace_billing::Entity::find_by_id(workspace_id)
.lock_exclusive()
.one(&txn)
.await?
.ok_or_else(|| AgentError::Internal("Workspace billing account not found".into()))?;
if workspace_billing.balance < total_cost {
txn.rollback().await?;
return Ok(BillingResult::InsufficientBalance {
message: format!(
"Insufficient balance. Project: {:.4} {}, Workspace: {:.4} {}, Required: {:.4} {}",
project_billing.balance, currency,
workspace_billing.balance, currency,
total_cost, currency
),
});
}
let amount_dec = -total_cost;
workspace_billing_history::ActiveModel {
uid: Set(Uuid::new_v4()),
workspace_id: Set(workspace_id),
user_id: Set(Some(proj.created_by)),
amount: Set(amount_dec),
currency: Set(currency.clone()),
reason: Set(format!("ai_usage:{}", project_uid)),
extra: Set(Some(serde_json::json!({
"project_id": project_uid.to_string(),
"model_id": model_id.to_string(),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"fallback_reason": "project_balance_insufficient"
}))),
created_at: Set(now),
}
.insert(&txn)
.await?;
let new_balance = workspace_billing.balance - total_cost;
let mut updated: workspace_billing::ActiveModel = workspace_billing.into();
updated.balance = Set(new_balance);
updated.updated_at = Set(now);
updated.update(&txn).await?;
txn.commit().await?;
let cost_f64 = total_cost.to_string().parse().unwrap_or(0.0);
tracing::info!(
project_id = %project_uid,
model_id = %model_id,
input_tokens = input_tokens,
output_tokens = output_tokens,
cost = %cost_f64,
currency = %currency,
workspace_id = %workspace_id.to_string(),
source = "workspace_fallback",
"ai_usage_recorded"
);
Ok(BillingResult::Success(BillingRecord {
cost: cost_f64,
currency,
input_tokens,
output_tokens,
}))
} else {
// ── Project insufficient and no workspace ───────────────────
txn.rollback().await?;
Ok(BillingResult::InsufficientBalance {
message: format!(
"Insufficient balance. Required: {:.4} {}, Available: {:.4} {}",
total_cost, currency, project_billing.balance, currency
),
})
}
}

View File

@ -58,17 +58,24 @@ async fn record_ai_session(
output_tokens: i64,
latency_ms: i64,
) {
let (cost, currency) = match billing::record_ai_usage(
let (cost, currency, error_msg) = match billing::record_ai_usage(
db,
project_id,
model_id,
version_id,
input_tokens,
output_tokens,
)
.await
{
Ok(record) => (Some(record.cost), Some(record.currency)),
Err(_) => (None, None),
Ok(billing::BillingResult::Success(record)) => {
(Some(record.cost), Some(record.currency), None)
}
Ok(billing::BillingResult::InsufficientBalance { message }) => {
// Create system message for insufficient balance
create_system_message(db, room_id, &message).await;
(None, None, Some(message))
}
Err(_) => (None, None, None),
};
let _ = models::ai::ai_session::ActiveModel {
@ -81,7 +88,7 @@ async fn record_ai_session(
latency_ms: Set(Some(latency_ms)),
cost: Set(cost),
currency: Set(currency),
error_message: Set(None),
error_message: Set(error_msg),
error_code: Set(None),
created_at: Set(chrono::Utc::now()),
}
@ -89,6 +96,71 @@ async fn record_ai_session(
.await;
}
/// Create a system message in the room for billing errors.
async fn create_system_message(
db: &db::database::AppDatabase,
room_id: Uuid,
message: &str,
) {
use models::rooms::{room_message, MessageSenderType, MessageContentType};
use sea_orm::Set;
// Get next sequence number - we don't have cache here, so we query directly
let last_seq = match room_message::Entity::find()
.filter(room_message::Column::Room.eq(room_id))
.order_by_desc(room_message::Column::Seq)
.one(db)
.await
{
Ok(Some(m)) => m.seq,
Ok(None) => 0,
Err(e) => {
tracing::warn!(error = %e, "Failed to get last seq for system message");
return;
}
};
let seq = last_seq + 1;
let now = chrono::Utc::now();
let result = room_message::ActiveModel {
id: Set(Uuid::new_v4()),
seq: Set(seq),
room: Set(room_id),
sender_type: Set(MessageSenderType::System),
sender_id: Set(None),
model_id: Set(None),
thread: Set(None),
in_reply_to: Set(None),
content: Set(message.to_string()),
content_type: Set(MessageContentType::Text),
thinking_content: Set(None),
edited_at: Set(None),
send_at: Set(now),
revoked: Set(None),
revoked_by: Set(None),
}
.insert(db)
.await;
match result {
Ok(_) => {
tracing::info!(
room_id = %room_id,
message = %message,
"system_message_created_for_billing_error"
);
}
Err(e) => {
tracing::warn!(
error = %e,
room_id = %room_id,
"Failed to create system message for billing error"
);
}
}
}
/// Service for handling AI chat requests in rooms.
pub struct ChatService {
ai_base_url: Option<String>,
@ -614,7 +686,26 @@ impl ChatService {
for call in &calls {
let start = std::time::Instant::now();
let executor = crate::tool::ToolExecutor::new();
let results = match executor.execute_batch(vec![call.clone()], &mut ctx).await {
// Use select! loop to send heartbeat chunks at 30s intervals
// during long tool execution, resetting the frontend streaming timer.
let fut = executor.execute_batch(vec![call.clone()], &mut ctx);
tokio::pin!(fut);
let results = loop {
tokio::select! {
result = fut.as_mut() => break result,
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
on_chunk(AiStreamChunk {
content: String::new(),
done: false,
chunk_type: AiChunkType::ToolCall,
}).await;
}
}
};
let results = match results {
Ok(r) => r,
Err(e) => {
let elapsed = start.elapsed().as_millis() as i64;

View File

@ -760,9 +760,9 @@ async fn call_stream_once(
})
};
// 60s timeout for the entire stream
match tokio::time::timeout(std::time::Duration::from_secs(60), stream_fut).await {
// 120s timeout for the entire stream
match tokio::time::timeout(std::time::Duration::from_secs(120), stream_fut).await {
Ok(result) => result,
Err(_) => Err(AgentError::Timeout { task_id: 0, seconds: 60 }),
Err(_) => Err(AgentError::Timeout { task_id: 0, seconds: 120 }),
}
}

View File

@ -13,7 +13,7 @@ pub mod sync;
pub mod task;
pub mod tokent;
pub mod tool;
pub use billing::{BillingRecord, record_ai_usage};
pub use billing::{BillingRecord, BillingResult, record_ai_usage};
pub use sync::list_accessible_models;
pub use task::TaskService;
pub use tokent::{TokenUsage, resolve_usage};

View File

@ -16,8 +16,8 @@ async fn git_blob_info_exec(
let oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let info = domain.blob_get(&commit_oid).map_err(|e| e.to_string())?;
let blob_oid = git::commit::types::CommitOid::new(oid);
let info = domain.blob_get(&blob_oid).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"oid": info.oid.to_string(),
@ -37,10 +37,10 @@ async fn git_blob_exists_exec(
let oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let exists = domain.blob_exists(&commit_oid);
let blob_oid = git::commit::types::CommitOid::new(oid);
let exists = domain.blob_exists(&blob_oid);
Ok(serde_json::json!({ "oid": commit_oid.to_string(), "exists": exists }))
Ok(serde_json::json!({ "oid": blob_oid.to_string(), "exists": exists }))
}
async fn git_blob_content_exec(
@ -55,8 +55,8 @@ async fn git_blob_content_exec(
let max_size = p.get("max_size").and_then(|v| v.as_u64()).unwrap_or(1_048_576) as usize; // 1MB default
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let blob = domain.blob_content(&commit_oid).map_err(|e| e.to_string())?;
let blob_oid = git::commit::types::CommitOid::new(oid);
let blob = domain.blob_content(&blob_oid).map_err(|e| e.to_string())?;
if blob.size > max_size {
return Err(format!(
@ -109,18 +109,6 @@ async fn git_blob_create_exec(
}))
}
fn resolve_oid(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_info

View File

@ -47,16 +47,11 @@ async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
/// Resolve a rev string to commit metadata. Tries full OID first (exactly 40 hex chars),
/// then reference name resolution (branch, tag, HEAD), then hex prefix lookup.
/// Resolve a rev string to commit metadata using the full rev-parse machinery
/// (branch names, tags, HEAD, hex prefixes, etc.).
fn resolve_commit(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitMeta, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
domain.commit_get(&oid).map_err(|e| e.to_string())
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())
}
let oid = domain.resolve_rev(rev).map_err(|e| e.to_string())?;
domain.commit_get(&oid).map_err(|e| e.to_string())
}
async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {

View File

@ -5,16 +5,10 @@ use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use base64::Engine;
use std::collections::HashMap;
/// Resolve a rev string to a commit OID. Tries full OID first (exactly 40 hex chars),
/// then reference name resolution (branch, tag, HEAD), then hex prefix lookup.
/// Resolve a rev string to a commit OID using the full rev-parse machinery
/// (branch names, tags, HEAD, hex prefixes, etc.).
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else if let Ok(Some(oid)) = domain.ref_target(rev) {
Ok(oid)
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
domain.resolve_rev(rev).map_err(|e| e.to_string())
}
async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
@ -54,14 +48,18 @@ async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
let commit_oid = resolve_commit_oid(&domain, &rev)?;
// Get tree OID from commit
let commit_meta = domain.commit_get(&commit_oid).map_err(|e| e.to_string())?;
let tree_oid = &commit_meta.tree_id;
let entries = match dir_path {
Some(ref dp) => {
let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?;
let entry = domain.tree_entry_by_path(tree_oid, dp).map_err(|e| e.to_string())?;
domain.tree_list(&entry.oid).map_err(|e| e.to_string())?
}
None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?,
None => domain.tree_list(tree_oid).map_err(|e| e.to_string())?,
};
let result: Vec<_> = entries.iter().map(|e| {

View File

@ -4,6 +4,7 @@ mod m20260420_000003_add_model_id_to_room_message;
pub mod m20260421_000001_add_agent_type_to_room_ai;
pub mod m20260426_000001_add_thinking_content_to_room_message;
pub mod m20260428_000001_backfill_content_tsv;
pub mod m20260428_000002_default_use_exact_true;
pub async fn execute_sql(manager: &SchemaManager<'_>, sql: &str) -> Result<(), DbErr> {
for stmt in split_sql_statements(sql) {
@ -93,6 +94,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260421_000001_add_agent_type_to_room_ai::Migration),
Box::new(m20260426_000001_add_thinking_content_to_room_message::Migration),
Box::new(m20260428_000001_backfill_content_tsv::Migration),
Box::new(m20260428_000002_default_use_exact_true::Migration),
// Repo tables
Box::new(m20250628_000028_create_repo::Migration),
Box::new(m20250628_000029_create_repo_branch::Migration),

View File

@ -0,0 +1,17 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!("sql/m20260428_000002_default_use_exact_true.sql");
super::execute_sql(manager, sql).await
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
// No-op: data migration is non-reversible
Ok(())
}
}

View File

@ -0,0 +1,4 @@
-- Fix: set use_exact = true for all existing room_ai records
-- This changes the default behavior so AI only responds when explicitly @mentioned.
-- Previously use_exact defaulted to false, causing AI to reply to every message.
UPDATE room_ai SET use_exact = true WHERE use_exact = false;

View File

@ -70,7 +70,7 @@ impl RoomService {
active.max_tokens = Set(request.max_tokens);
}
if request.use_exact.is_some() {
active.use_exact = Set(request.use_exact.unwrap_or(false));
active.use_exact = Set(request.use_exact.unwrap_or(true));
}
if request.think.is_some() {
active.think = Set(request.think.unwrap_or(false));
@ -97,7 +97,7 @@ impl RoomService {
system_prompt: Set(request.system_prompt),
temperature: Set(request.temperature),
max_tokens: Set(request.max_tokens),
use_exact: Set(request.use_exact.unwrap_or(false)),
use_exact: Set(request.use_exact.unwrap_or(true)),
think: Set(request.think.unwrap_or(false)),
stream: Set(request.stream.unwrap_or(false)),
min_score: Set(request.min_score),

View File

@ -12,13 +12,21 @@ impl AppService {
input_tokens: i64,
output_tokens: i64,
) -> Result<agent::billing::BillingRecord, AppError> {
Ok(agent::billing::record_ai_usage(
use agent::billing::BillingResult;
match agent::billing::record_ai_usage(
&self.db,
project_uid,
model_id,
input_tokens,
output_tokens,
)
.await?)
.await?
{
BillingResult::Success(record) => Ok(record),
BillingResult::InsufficientBalance { message } => {
Err(AppError::BadRequest(message))
}
}
}
}

204
public/load.html Normal file
View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>Topo Draw Animation</title>
<style>
body {
margin: 0;
background: #ffffff;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
svg {
width: 320px;
}
path {
fill-opacity: 0;
stroke: rgb(0, 0, 0);
stroke-width: 1;
stroke-dasharray: 0;
stroke-dashoffset: 0;
}
</style>
</head>
<body>
<svg id="logo" viewBox="0 0 400 400" width="100" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg"
desc="Created with imagetracer.js version 1.2.6">
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 335.5 20 Q 341.3 19.3 342 23.5 L 347 41.5 L 347 63.5 L 344 79.5 L 336 101 Q 333.3 102.1 334 99.5 Q 320.8 70.7 297.5 52 L 287 43.5 Q 285.9 40.2 288.5 41 L 313.5 27 L 335.5 20 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 95.5 21 L 105.5 22 L 115.5 25 L 147 41.5 L 145.5 45 L 130 57 L 110 83.5 L 100.5 102 L 94 88.5 L 90 70.5 Q 91.3 65.8 89 64.5 L 89 38.5 L 92 25.5 L 95.5 21 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 154.5 112 Q 164 110.5 169.5 113 L 183 123.5 L 189 136.5 L 189 147.5 Q 186.4 158.4 178.5 164 L 167.5 169 L 156.5 169 L 150.5 167 L 140.5 159 Q 124.4 174.2 120 200 Q 114.6 199.4 112 194.5 L 100 179.5 L 96 171.5 Q 107.1 144.1 127.5 126 Q 138.8 116.8 154.5 112 Z M 158 123 L 152 126 Q 145 130 143 140 L 143 148 L 147 157 Q 152 162 161 164 Q 168 164 173 161 Q 178 157 181 149 Q 183 135 176 130 Q 171 122 158 123 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 269.5 112 Q 281.6 110.4 288.5 114 L 302.5 121 L 319 136.5 L 332 154.5 L 338 168.5 L 338 172.5 Q 330.1 188.6 317.5 200 L 315.5 201 Q 312.3 185.2 306 172.5 L 294.5 160 L 292.5 159 Q 286.5 170 268.5 169 Q 257.4 166.6 252 158.5 L 247 147.5 L 247 136.5 Q 249.6 125.6 256.5 119 L 269.5 112 Z M 272 123 L 264 126 Q 258 130 255 139 Q 253 151 259 157 Q 263 162 271 164 L 278 164 L 288 158 L 293 147 L 293 140 L 288 130 Q 283 123 272 123 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 154.5 130 Q 160.8 128.8 162 132.5 Q 162.6 137.6 159.5 139 Q 154.3 140.3 153 137.5 L 152 133.5 L 154.5 130 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 266.5 130 Q 272 129 273 132.5 L 272 139 L 268.5 140 L 264 136.5 Q 263 131 266.5 130 Z " />
<path fill="rgb(249,248,247)" stroke="rgb(249,248,247)" stroke-width="1" opacity="1"
d="M 206.5 154 L 226.5 154 L 235.5 156 L 249.5 162 L 266 175.5 Q 274.8 185.2 279 199.5 L 279 216.5 L 273.5 227 L 254.5 233 L 237.5 235 L 236.5 236 L 197.5 236 L 167.5 230 L 159 225.5 L 156 214.5 L 156 204.5 Q 159.9 184.9 171.5 173 Q 173.8 173.8 173 171.5 Q 185 158 206.5 154 Z M 213 162 L 206 165 L 202 170 Q 201 176 204 179 L 214 184 L 219 184 L 227 181 L 233 174 L 232 168 Q 226 161 213 162 Z M 196 189 L 194 191 L 196 198 Q 203 206 216 208 L 225 207 L 235 202 L 242 194 L 242 190 L 237 191 L 234 197 L 222 203 L 214 203 L 204 199 Q 199 196 198 191 L 196 189 Z " />
<path fill="rgb(6,6,6)" stroke="rgb(6,6,6)" stroke-width="1" opacity="0.9372549019607843"
d="M 87.5 4 L 97.5 4 L 115.5 8 L 142.5 20 L 161.5 34 L 164.5 34 L 176.5 28 L 190.5 24 L 200.5 22 L 221.5 21 L 222.5 22 L 233.5 22 L 247.5 25 L 272.5 35 Q 288.3 20.8 309.5 12 L 330.5 5 L 346.5 4 L 354 9.5 L 359 19.5 L 362 35.5 L 362 64.5 L 356 91.5 L 342 121.5 L 350 144.5 L 350 159.5 Q 340.5 189 319.5 207 L 303.5 220 L 297 223 L 296 225.5 Q 302.8 234.2 306 246.5 L 310 263.5 L 312 282.5 Q 322.5 292 328 306.5 L 330 312.5 L 331 325.5 L 327 341.5 L 336 349.5 L 342 361.5 L 342 368.5 Q 340 378.5 332.5 383 L 322.5 386 L 297.5 386 L 296.5 385 L 284.5 385 L 273.5 393 Q 266.3 396.3 254.5 395 Q 241.6 392.4 235 383.5 L 233 379 L 204.5 379 L 203.5 378 L 201 379 Q 199.9 385.6 194.5 389 L 180.5 395 L 168.5 395 L 148.5 385 L 147.5 386 L 112.5 386 L 100.5 382 L 96 377.5 Q 90.8 371.7 93 358.5 Q 94.4 351.9 99 348.5 L 99 346 Q 73.7 335 57 314.5 L 43 294.5 L 37 280.5 L 32 261.5 L 32 245.5 Q 33.9 236.4 40.5 232 Q 44.9 227.9 54.5 229 L 66.5 234 L 77.5 242 L 99.5 254 L 121.5 264 L 125 264 L 130 244.5 L 140 226.5 L 140 224 L 134.5 222 L 123.5 214 L 108.5 199 L 107 199 L 95 183.5 L 88 169.5 L 86 162.5 L 86 143.5 L 93 118.5 L 80 92.5 L 75 74.5 L 73 63.5 L 73 36.5 L 78 16.5 Q 80.2 9.2 85.5 5 L 87.5 4 Z M 91 10 L 89 11 L 84 18 L 79 39 L 79 64 L 81 75 L 87 94 L 94 110 L 97 112 L 97 108 L 87 85 L 83 65 L 83 40 Q 86 38 84 33 Q 85 23 90 18 L 94 15 L 106 16 L 129 24 Q 143 29 154 39 Q 157 40 156 38 L 138 24 L 118 15 L 107 13 L 103 11 L 91 10 Z M 338 10 L 329 13 L 321 14 L 311 18 L 281 36 L 279 39 L 284 38 L 311 22 L 327 16 L 338 14 L 344 16 L 349 25 L 353 42 L 353 65 L 351 71 L 351 76 L 346 93 L 338 110 Q 337 114 341 112 L 353 82 L 357 62 L 357 38 L 353 20 L 351 16 Q 349 8 338 10 Z M 337 19 L 317 25 L 288 41 Q 285 40 286 44 L 305 59 Q 323 76 334 101 Q 333 103 336 102 L 342 89 L 346 74 L 347 60 L 348 59 L 347 39 L 342 23 L 337 19 Z M 95 21 L 90 29 L 89 40 L 88 41 L 88 63 L 89 64 L 90 75 L 94 91 L 101 103 Q 111 79 128 61 L 147 44 Q 150 45 148 41 L 118 25 L 104 21 L 95 21 Z M 213 27 L 212 28 L 201 28 L 188 31 L 167 39 L 151 48 L 132 65 Q 133 67 131 66 Q 102 99 91 150 Q 91 163 95 173 L 113 197 Q 131 214 156 226 L 159 226 L 164 229 L 177 233 L 193 236 L 201 236 L 202 237 L 232 237 L 233 236 L 242 236 L 269 230 L 295 218 L 320 199 L 333 183 Q 340 172 344 158 L 344 146 L 333 111 L 326 96 L 306 67 L 286 50 L 268 39 L 243 30 L 233 28 L 213 27 Z M 289 227 L 279 232 L 255 239 L 238 241 L 237 242 L 224 242 L 223 243 L 198 242 L 197 241 L 185 240 L 168 236 L 145 228 L 133 254 L 129 272 L 129 299 Q 132 300 130 306 L 142 348 L 152 350 L 169 349 L 192 341 L 189 320 L 177 311 L 166 298 L 167 296 Q 175 307 188 314 L 208 321 L 226 321 Q 227 319 232 320 L 251 312 L 259 306 L 264 300 L 269 296 L 265 305 L 250 317 L 247 320 L 243 340 L 250 344 L 267 349 L 288 349 L 292 348 L 294 345 L 298 333 L 306 301 L 306 275 L 305 274 L 304 262 Q 300 244 292 230 L 289 227 Z M 47 236 L 45 237 Q 39 241 38 249 L 38 258 L 41 275 L 47 288 Q 46 290 49 289 Q 55 273 66 262 L 78 252 Q 81 253 79 249 L 57 236 L 47 236 Z M 107 265 L 102 269 L 93 279 L 90 280 L 77 302 L 74 311 L 74 324 L 99 340 L 106 342 L 104 334 Q 102 321 105 313 Q 111 296 123 286 Q 122 276 124 271 L 111 265 L 107 265 Z M 313 293 L 311 295 Q 310 317 303 334 L 321 338 L 324 331 Q 326 319 323 312 Q 320 300 313 293 Z M 123 294 Q 114 303 110 318 Q 109 331 111 339 L 124 336 L 131 336 L 132 335 L 126 314 L 124 296 L 123 294 Z M 194 321 L 195 332 L 202 362 L 202 371 L 207 373 L 232 373 L 234 353 L 242 326 L 242 321 L 228 325 L 208 325 L 194 321 Z " />
<path fill="rgb(6,6,6)" stroke="rgb(6,6,6)" stroke-width="1" opacity="0.9372549019607843"
d="M 157.5 124 Q 171.5 122.5 176 130.5 Q 181 135.5 181 145.5 Q 179.3 156.3 171.5 161 L 165.5 163 L 159.5 163 Q 151 161.5 147 155.5 Q 142.5 150 144 138.5 Q 145.3 131.8 149.5 128 L 157.5 124 Z M 156 129 L 153 130 L 152 133 L 153 139 L 156 140 L 161 139 L 163 135 L 162 132 Q 161 128 156 129 Z " />
<path fill="rgb(6,6,6)" stroke="rgb(6,6,6)" stroke-width="1" opacity="0.9372549019607843"
d="M 270.5 124 Q 283.2 123.3 288 130.5 Q 293.7 135.8 292 148.5 L 285.5 159 L 278.5 163 L 269.5 163 Q 262.3 161.2 259 155.5 L 256 150.5 L 255 142.5 Q 256.7 131.2 264.5 126 L 270.5 124 Z M 267 129 Q 262 130 263 137 Q 264 141 271 140 Q 275 139 274 133 Q 273 128 267 129 Z " />
<path fill="rgb(6,6,6)" stroke="rgb(6,6,6)" stroke-width="1" opacity="0.9372549019607843"
d="M 211.5 163 Q 226.5 161 232 168.5 L 233 172.5 L 225.5 181 Q 221.1 184.1 212.5 183 Q 206.8 181.8 204 177.5 L 202 172.5 L 206.5 165 L 211.5 163 Z " />
<path fill="rgb(6,6,6)" stroke="rgb(6,6,6)" stroke-width="1" opacity="0.9372549019607843"
d="M 195 190 L 198 191.5 L 202.5 199 Q 208.4 204.1 219.5 204 Q 231.5 202 237 193.5 L 238.5 191 L 240.5 190 L 241 193.5 L 233.5 202 L 222.5 207 L 212.5 207 Q 202.4 204.6 197 197.5 L 195 190 Z " />
<path fill="rgb(0,0,0)" stroke="rgb(0,0,0)" stroke-width="1" opacity="0"
d="M 0 0 L 400 0 L 400 400 L 0 400 L 0 0 Z M 88 4 L 86 5 Q 80 9 78 17 L 73 37 L 73 64 L 75 75 L 80 93 L 93 119 L 86 144 L 86 163 L 88 170 L 95 184 L 107 199 L 109 199 L 124 214 L 135 222 L 140 224 L 140 227 L 130 245 L 125 264 L 122 264 L 100 254 L 78 242 L 67 234 L 55 229 Q 45 228 41 232 Q 34 236 32 246 L 32 262 L 37 281 L 43 295 L 57 315 Q 74 335 99 346 L 99 349 Q 94 352 93 359 Q 91 372 96 378 L 101 382 L 113 386 L 148 386 L 149 385 L 169 395 L 181 395 L 195 389 Q 200 386 201 379 L 204 378 L 205 379 L 233 379 L 235 384 Q 242 392 255 395 Q 266 396 274 393 L 285 385 L 297 385 L 298 386 L 323 386 L 333 383 Q 340 378 342 369 L 342 362 L 336 350 L 327 342 L 331 326 L 330 313 L 328 307 Q 322 292 312 283 L 310 264 L 306 247 Q 303 234 296 226 L 297 223 L 304 220 L 320 207 Q 341 189 350 160 L 350 145 L 342 122 L 356 92 L 362 65 L 362 36 L 359 20 L 354 10 L 347 4 L 331 5 L 310 12 Q 288 21 273 35 L 248 25 L 234 22 L 223 22 L 222 21 L 201 22 L 191 24 L 177 28 L 165 34 L 162 34 L 143 20 L 116 8 L 98 4 L 88 4 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 90.5 10 L 102.5 11 L 106.5 13 L 117.5 15 L 137.5 24 L 156 37.5 Q 156.8 40.1 153.5 39 Q 143.1 29.4 128.5 24 L 105.5 16 L 93.5 15 L 90 17.5 Q 85.5 23.5 84 32.5 Q 85.5 38 83 39.5 L 83 64.5 L 87 84.5 L 97 107.5 L 97 111.5 L 94 109.5 L 87 93.5 L 81 74.5 L 79 63.5 L 79 38.5 L 84 17.5 L 88.5 11 L 90.5 10 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 337.5 10 Q 348.6 8.4 351 15.5 L 353 19.5 L 357 37.5 L 357 61.5 L 353 81.5 L 341 112 Q 336.9 113.7 338 109.5 L 346 92.5 L 351 75.5 L 351 70.5 L 353 64.5 L 353 41.5 L 349 24.5 L 343.5 16 L 337.5 14 L 326.5 16 L 310.5 22 L 283.5 38 L 279 39 L 280.5 36 L 310.5 18 L 320.5 14 L 328.5 13 L 337.5 10 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 212.5 27 L 232.5 28 L 242.5 30 L 267.5 39 L 285.5 50 L 306 67 L 326 95.5 L 333 110.5 L 344 145.5 L 344 157.5 L 338.5 172 Q 335.5 159 329 149.5 L 309.5 127 L 302.5 121 L 288.5 114 Q 281.6 110.4 269.5 112 Q 259.9 114.9 254 121.5 L 247 136.5 L 247 147.5 Q 249.9 159.1 258.5 165 L 268.5 169 L 278.5 169 Q 287.5 166 292.5 159 L 304 169.5 L 310 181.5 L 315.5 201 L 317 200.5 Q 298.4 219.4 270.5 229 L 270.5 228 L 275 225.5 L 279 216.5 L 279 199.5 Q 274.5 184 264.5 174 L 249.5 162 L 235.5 156 L 226.5 154 L 206.5 154 Q 185 158 173 171.5 Q 173.8 173.8 171.5 173 Q 163.3 181.3 159 193.5 L 156 204.5 L 156 214.5 L 160 226.5 L 158.5 226 L 155.5 226 L 140.5 218 L 117 199.5 L 118.5 200 Q 121.9 200.9 120 195.5 L 127 177.5 Q 132.3 166.8 140.5 159 L 150.5 167 Q 156.6 170.4 167.5 169 Q 178.3 166.3 184 158.5 Q 190.7 151.7 189 136.5 Q 186 124.5 177.5 118 Q 171.1 109.9 154.5 112 Q 132.3 118.8 119 134.5 Q 104.1 150.1 96 172.5 L 96.5 174 L 93 166.5 L 91 149.5 Q 101.6 98.6 130.5 66 Q 132.8 66.8 132 64.5 L 150.5 48 L 166.5 39 L 187.5 31 L 200.5 28 L 211.5 28 L 212.5 27 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 297.5 35 L 296.5 37 L 292.5 39 L 293.5 37 L 297.5 35 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 88.5 40 L 89 62.5 L 88 62.5 L 88.5 40 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 286.5 41 L 290 46.5 L 286 43.5 L 286.5 41 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 347.5 46 L 348 58.5 L 347 58.5 L 347.5 46 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 140.5 48 L 136.5 53 L 140.5 48 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 297.5 52 L 303.5 59 L 297.5 52 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 127.5 61 L 121.5 68 L 127.5 61 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 306.5 61 L 314.5 70 L 306.5 61 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 157.5 123 L 166 123.5 L 155.5 125 L 155.5 124 L 157.5 123 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 263.5 126 L 259.5 131 L 263.5 126 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 266.5 129 Q 273 128 274 132.5 Q 275 137 272.5 138 L 273 136.5 Q 274 131 270.5 130 Q 265.5 129 264.5 132 L 266.5 129 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 176.5 131 L 180 136.5 L 179 136.5 L 176.5 131 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 263.5 133 Q 263.3 137.7 266.5 139 L 268 139.5 L 266.5 140 Q 261.6 138.6 263.5 133 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 143.5 139 L 144 147.5 L 143 147.5 L 143.5 139 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 176.5 156 L 172.5 161 L 176.5 156 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 259.5 156 L 263.5 161 L 259.5 156 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 212.5 162 L 222 162.5 L 212.5 163 L 212.5 162 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 231.5 175 L 226.5 181 L 231.5 175 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 106.5 188 L 111.5 194 L 106.5 188 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 326.5 190 L 321.5 196 L 326.5 190 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 195.5 194 L 201 201.5 L 196 197.5 L 195.5 194 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 239.5 196 L 234.5 202 L 239.5 196 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 227.5 200 L 229 200.5 L 222.5 203 L 222.5 202 L 227.5 200 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 288.5 227 L 292 229.5 Q 299.7 243.8 304 261.5 L 305 273.5 L 306 274.5 L 306 300.5 L 298 332.5 L 294 344.5 L 291.5 348 L 287.5 349 L 266.5 349 L 249.5 344 L 243 339.5 L 247 319.5 L 249.5 317 L 265 304.5 L 269 295.5 L 264 299.5 L 258.5 306 L 250.5 312 L 231.5 320 Q 226.7 318.8 225.5 321 L 207.5 321 L 187.5 314 Q 175.3 306.7 166.5 296 L 166 297.5 L 176.5 311 L 189 319.5 L 192 341 L 168.5 349 L 151.5 350 L 142 348 L 130 305.5 Q 131.5 300 129 298.5 L 129 271.5 L 133 253.5 L 145 228 L 167.5 236 L 184.5 240 L 196.5 241 L 197.5 242 L 222.5 243 L 223.5 242 L 236.5 242 L 237.5 241 L 254.5 239 L 278.5 232 L 288.5 227 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 46.5 236 L 56.5 236 L 79 249 Q 80.5 252.7 77.5 252 L 66 261.5 Q 54.7 272.7 48.5 289 Q 46.3 289.8 47 287.5 L 41 274.5 L 38 257.5 L 38 248.5 Q 39.2 240.7 44.5 237 L 46.5 236 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 201.5 236 L 232 236.5 L 201.5 237 L 201.5 236 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 106.5 265 L 110.5 265 L 124 271 Q 122.2 276.3 123 285.5 Q 110.8 295.8 105 312.5 Q 102.3 320.8 104 333.5 L 106 342 L 98.5 340 L 74 324 L 74 310.5 L 77 301.5 L 90 280 L 93 278.5 L 101.5 269 L 106.5 265 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 312.5 293 Q 319.7 300.3 323 311.5 Q 325.5 319 324 330.5 L 321 338 L 303 333.5 Q 309.5 316.5 311 294.5 L 312.5 293 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 122.5 294 L 124 295.5 L 126 313.5 L 132 334.5 L 130.5 336 L 123.5 336 L 111 339 Q 108.6 331.1 110 317.5 Q 113.6 303.1 122.5 294 Z " />
<path fill="rgb(220,67,4)" stroke="rgb(220,67,4)" stroke-width="1" opacity="0.996078431372549"
d="M 194 321 L 207.5 325 L 227.5 325 L 242 321 L 242 325.5 L 234 352.5 L 232 373 L 206.5 373 L 202 370.5 L 202 361.5 L 195 331.5 L 194 321 Z " />
</svg>
<script>
const paths = Array.from(document.querySelectorAll('#logo path'));
function getStartPoint(path) {
const p = path.getPointAtLength(0);
return { x: p.x, y: p.y };
}
function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function sortPaths(paths) {
const remaining = [...paths];
const result = [];
let current = remaining.shift();
result.push(current);
while (remaining.length) {
const currPt = getStartPoint(current);
let minIdx = 0;
let minDist = Infinity;
for (let i = 0; i < remaining.length; i++) {
const pt = getStartPoint(remaining[i]);
const d = dist(currPt, pt);
if (d < minDist) {
minDist = d;
minIdx = i;
}
}
current = remaining.splice(minIdx, 1)[0];
result.push(current);
}
return result;
}
paths.forEach(p => {
const len = p.getTotalLength();
p.style.strokeDasharray = len;
p.style.strokeDashoffset = len;
});
const ordered = sortPaths(paths);
let delay = 0;
ordered.forEach((p, i) => {
const len = p.getTotalLength();
p.style.transition = `stroke-dashoffset 0.4s ease ${delay}s`;
requestAnimationFrame(() => {
p.style.strokeDashoffset = 0;
});
delay += 0.01;
});
setTimeout(() => {
paths.forEach(p => {
p.style.transition = "fill-opacity 0.8s ease";
p.style.fillOpacity = 1;
p.style.stroke = "none";
});
}, delay * 1000 + 300);
</script>
</body>
</html>

View File

@ -3,7 +3,7 @@ import {useQuery} from '@tanstack/react-query';
import {workspaceList, workspaceInfo} from '@/client';
import type {WorkspaceInfoResponse} from '@/client';
import {WorkspaceSidebar} from '@/components/layout/workspace-sidebar';
import {Spinner} from '@/components/ui/spinner';
import LoadingAnimation from '@/components/ui/loading-animation';
export default function HomePageLayout() {
const {data, isLoading} = useQuery({
@ -28,11 +28,7 @@ export default function HomePageLayout() {
});
if (isLoading || infoLoading) {
return (
<div className="flex h-screen w-full items-center justify-center bg-background">
<Spinner/>
</div>
);
return <LoadingAnimation/>;
}
if (!workspaceInfoData) {

View File

@ -2,7 +2,7 @@ import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {workspaceList} from '@/client';
import {Spinner} from '@/components/ui/spinner';
import LoadingAnimation from '@/components/ui/loading-animation';
export function WorkspaceRedirect() {
const navigate = useNavigate();
@ -25,9 +25,9 @@ export function WorkspaceRedirect() {
}
}, [isLoading, data, navigate]);
return (
<div className="flex items-center justify-center h-screen">
<Spinner/>
</div>
);
if (isLoading) {
return <LoadingAnimation />;
}
return null;
}

View File

@ -1,5 +1,5 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { Spinner } from '@/components/ui/spinner';
import LoadingAnimation from '@/components/ui/loading-animation';
import { useUser } from '@/contexts/user-context';
export function ProtectedRoute() {
@ -7,11 +7,7 @@ export function ProtectedRoute() {
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-svh items-center justify-center">
<Spinner className="size-6" />
</div>
);
return <LoadingAnimation />;
}
if (!isAuthenticated) {

View File

@ -23,7 +23,7 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
<div className="w-full mb-2">
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
onClick={() => navigate('/invitations')}>
<span className="flex h-6 items-center shrink-0 w-6">
<span className={cn('flex h-6 items-center shrink-0 w-6', collapsed && 'justify-center')}>
<UserPlus className="h-4 w-4"/>
</span>
{!collapsed && <span className="text-sm leading-none">Invitations</span>}

View File

@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react';
const pathsData = [
'M 335.5 20 Q 341.3 19.3 342 23.5 L 347 41.5 L 347 63.5 L 344 79.5 L 336 101 Q 333.3 102.1 334 99.5 Q 320.8 70.7 297.5 52 L 287 43.5 Q 285.9 40.2 288.5 41 L 313.5 27 L 335.5 20 Z',
'M 95.5 21 L 105.5 22 L 115.5 25 L 147 41.5 L 145.5 45 L 130 57 L 110 83.5 L 100.5 102 L 94 88.5 L 90 70.5 Q 91.3 65.8 89 64.5 L 89 38.5 L 92 25.5 L 95.5 21 Z',
'M 154.5 112 Q 164 110.5 169.5 113 L 183 123.5 L 189 136.5 L 189 147.5 Q 186.4 158.4 178.5 164 L 167.5 169 L 156.5 169 L 150.5 167 L 140.5 159 Q 124.4 174.2 120 200 Q 114.6 199.4 112 194.5 L 100 179.5 L 96 171.5 Q 107.1 144.1 127.5 126 Q 138.8 116.8 154.5 112 Z M 158 123 L 152 126 Q 145 130 143 140 L 143 148 L 147 157 Q 152 162 161 164 Q 168 164 173 161 Q 178 157 181 149 Q 183 135 176 130 Q 171 122 158 123 Z',
'M 269.5 112 Q 281.6 110.4 288.5 114 L 302.5 121 L 319 136.5 L 332 154.5 L 338 168.5 L 338 172.5 Q 330.1 188.6 317.5 200 L 315.5 201 Q 312.3 185.2 306 172.5 L 294.5 160 L 292.5 159 Q 286.5 170 268.5 169 Q 257.4 166.6 252 158.5 L 247 147.5 L 247 136.5 Q 249.6 125.6 256.5 119 L 269.5 112 Z M 272 123 L 264 126 Q 258 130 255 139 Q 253 151 259 157 Q 263 162 271 164 L 278 164 L 288 158 L 293 147 L 293 140 L 288 130 Q 283 123 272 123 Z',
'M 154.5 130 Q 160.8 128.8 162 132.5 Q 162.6 137.6 159.5 139 Q 154.3 140.3 153 137.5 L 152 133.5 L 154.5 130 Z',
'M 266.5 130 Q 272 129 273 132.5 L 272 139 L 268.5 140 L 264 136.5 Q 263 131 266.5 130 Z',
'M 206.5 154 L 226.5 154 L 235.5 156 L 249.5 162 L 266 175.5 Q 274.8 185.2 279 199.5 L 279 216.5 L 273.5 227 L 254.5 233 L 237.5 235 L 236.5 236 L 197.5 236 L 167.5 230 L 159 225.5 L 156 214.5 L 156 204.5 Q 159.9 184.9 171.5 173 Q 173.8 173.8 173 171.5 Q 185 158 206.5 154 Z M 213 162 L 206 165 L 202 170 Q 201 176 204 179 L 214 184 L 219 184 L 227 181 L 233 174 L 232 168 Q 226 161 213 162 Z M 196 189 L 194 191 L 196 198 Q 203 206 216 208 L 225 207 L 235 202 L 242 194 L 242 190 L 237 191 L 234 197 L 222 203 L 214 203 L 204 199 Q 199 196 198 191 L 196 189 Z',
'M 87.5 4 L 97.5 4 L 115.5 8 L 142.5 20 L 161.5 34 L 164.5 34 L 176.5 28 L 190.5 24 L 200.5 22 L 221.5 21 L 222.5 22 L 233.5 22 L 247.5 25 L 272.5 35 Q 288.3 20.8 309.5 12 L 330.5 5 L 346.5 4 L 354 9.5 L 359 19.5 L 362 35.5 L 362 64.5 L 356 91.5 L 342 121.5 L 350 144.5 L 350 159.5 Q 340.5 189 319.5 207 L 303.5 220 L 297 223 L 296 225.5 Q 302.8 234.2 306 246.5 L 310 263.5 L 312 282.5 Q 322.5 292 328 306.5 L 330 312.5 L 331 325.5 L 327 341.5 L 336 349.5 L 342 361.5 L 342 368.5 Q 340 378.5 332.5 383 L 322.5 386 L 297.5 386 L 296.5 385 L 284.5 385 L 273.5 393 Q 266.3 396.3 254.5 395 Q 241.6 392.4 235 383.5 L 233 379 L 204.5 379 L 203.5 378 L 201 379 Q 199.9 385.6 194.5 389 L 180.5 395 L 168.5 395 L 148.5 385 L 147.5 386 L 112.5 386 L 100.5 382 L 96 377.5 Q 90.8 371.7 93 358.5 Q 94.4 351.9 99 348.5 L 99 346 Q 73.7 335 57 314.5 L 43 294.5 L 37 280.5 L 32 261.5 L 32 245.5 Q 33.9 236.4 40.5 232 Q 44.9 227.9 54.5 229 L 66.5 234 L 77.5 242 L 99.5 254 L 121.5 264 L 125 264 L 130 244.5 L 140 226.5 L 140 224 L 134.5 222 L 123.5 214 L 108.5 199 L 107 199 L 95 183.5 L 88 169.5 L 86 162.5 L 86 143.5 L 93 118.5 L 80 92.5 L 75 74.5 L 73 63.5 L 73 36.5 L 78 16.5 Q 80.2 9.2 85.5 5 L 87.5 4 Z M 91 10 L 89 11 L 84 18 L 79 39 L 79 64 L 81 75 L 87 94 L 94 110 L 97 112 L 97 108 L 87 85 L 83 65 L 83 40 Q 86 38 84 33 Q 85 23 90 18 L 94 15 L 106 16 L 129 24 Q 143 29 154 39 Q 157 40 156 38 L 138 24 L 118 15 L 107 13 L 103 11 L 91 10 Z M 338 10 L 329 13 L 321 14 L 311 18 L 281 36 L 279 39 L 284 38 L 311 22 L 327 16 L 338 14 L 344 16 L 349 25 L 353 42 L 353 65 L 351 71 L 351 76 L 346 93 L 338 110 Q 337 114 341 112 L 353 82 L 357 62 L 357 38 L 353 20 L 351 16 Q 349 8 338 10 Z M 337 19 L 317 25 L 288 41 Q 285 40 286 44 L 305 59 Q 323 76 334 101 Q 333 103 336 102 L 342 89 L 346 74 L 347 60 L 348 59 L 347 39 L 342 23 L 337 19 Z M 95 21 L 90 29 L 89 40 L 88 41 L 88 63 L 89 64 L 90 75 L 94 91 L 101 103 Q 111 79 128 61 L 147 44 Q 150 45 148 41 L 118 25 L 104 21 L 95 21 Z M 213 27 L 212 28 L 201 28 L 188 31 L 167 39 L 151 48 L 132 65 Q 133 67 131 66 Q 102 99 91 150 Q 91 163 95 173 L 113 197 Q 131 214 156 226 L 159 226 L 164 229 L 177 233 L 193 236 L 201 236 L 202 237 L 232 237 L 233 236 L 242 236 L 269 230 L 295 218 L 320 199 L 333 183 Q 340 172 344 158 L 344 146 L 333 111 L 326 96 L 306 67 L 286 50 L 268 39 L 243 30 L 233 28 L 213 27 Z M 289 227 L 279 232 L 255 239 L 238 241 L 237 242 L 224 242 L 223 243 L 198 242 L 197 241 L 185 240 L 168 236 L 145 228 L 133 254 L 129 272 L 129 299 Q 132 300 130 306 L 142 348 L 152 350 L 169 349 L 192 341 L 189 320 L 177 311 L 166 298 L 167 296 Q 175 307 188 314 L 208 321 L 226 321 Q 227 319 232 320 L 251 312 L 259 306 L 264 300 L 269 296 L 265 305 L 250 317 L 247 320 L 243 340 L 250 344 L 267 349 L 288 349 L 292 348 L 294 345 L 298 333 L 306 301 L 306 275 L 305 274 L 304 262 Q 300 244 292 230 L 289 227 Z M 47 236 L 45 237 Q 39 241 38 249 L 38 258 L 41 275 L 47 288 Q 46 290 49 289 Q 55 273 66 262 L 78 252 Q 81 253 79 249 L 57 236 L 47 236 Z M 107 265 L 102 269 L 93 279 L 90 280 L 77 302 L 74 311 L 74 324 L 99 340 L 106 342 L 104 334 Q 102 321 105 313 Q 111 296 123 286 Q 122 276 124 271 L 111 265 L 107 265 Z M 313 293 L 311 295 Q 310 317 303 334 L 321 338 L 324 331 Q 326 319 323 312 Q 320 300 313 293 Z M 123 294 Q 114 303 110 318 Q 109 331 111 339 L 124 336 L 131 336 L 132 335 L 126 314 L 124 296 L 123 294 Z M 194 321 L 195 332 L 202 362 L 202 371 L 207 373 L 232 373 L 234 353 L 242 326 L 242 321 L 228 325 L 208 325 L 194 321 Z',
'M 157.5 124 Q 171.5 122.5 176 130.5 Q 181 135.5 181 145.5 Q 179.3 156.3 171.5 161 L 165.5 163 L 159.5 163 Q 151 161.5 147 155.5 Q 142.5 150 144 138.5 Q 145.3 131.8 149.5 128 L 157.5 124 Z M 156 129 L 153 130 L 152 133 L 153 139 L 156 140 L 161 139 L 163 135 L 162 132 Q 161 128 156 129 Z',
'M 270.5 124 Q 283.2 123.3 288 130.5 Q 293.7 135.8 292 148.5 L 285.5 159 L 278.5 163 L 269.5 163 Q 262.3 161.2 259 155.5 L 256 150.5 L 255 142.5 Q 256.7 131.2 264.5 126 L 270.5 124 Z M 267 129 Q 262 130 263 137 Q 264 141 271 140 Q 275 139 274 133 Q 273 128 267 129 Z',
'M 211.5 163 Q 226.5 161 232 168.5 L 233 172.5 L 225.5 181 Q 221.1 184.1 212.5 183 Q 206.8 181.8 204 177.5 L 202 172.5 L 206.5 165 L 211.5 163 Z',
'M 195 190 L 198 191.5 L 202.5 199 Q 208.4 204.1 219.5 204 Q 231.5 202 237 193.5 L 238.5 191 L 240.5 190 L 241 193.5 L 233.5 202 L 222.5 207 L 212.5 207 Q 202.4 204.6 197 197.5 L 195 190 Z',
'M 0 0 L 400 0 L 400 400 L 0 400 L 0 0 Z M 88 4 L 86 5 Q 80 9 78 17 L 73 37 L 73 64 L 75 75 L 80 93 L 93 119 L 86 144 L 86 163 L 88 170 L 95 184 L 107 199 L 109 199 L 124 214 L 135 222 L 140 224 L 140 227 L 130 245 L 125 264 L 122 264 L 100 254 L 78 242 L 67 234 L 55 229 Q 45 228 41 232 Q 34 236 32 246 L 32 262 L 37 281 L 43 295 L 57 315 Q 74 335 99 346 L 99 349 Q 94 352 93 359 Q 91 372 96 378 L 101 382 L 113 386 L 148 386 L 149 385 L 169 395 L 181 395 L 195 389 Q 200 386 201 379 L 204 378 L 205 379 L 233 379 L 235 384 Q 242 392 255 395 Q 266 396 274 393 L 285 385 L 297 385 L 298 386 L 323 386 L 333 383 Q 340 378 342 369 L 342 362 L 336 350 L 327 342 L 331 326 L 330 313 L 328 307 Q 322 292 312 283 L 310 264 L 306 247 Q 303 234 296 226 L 297 223 L 304 220 L 320 207 Q 341 189 350 160 L 350 145 L 342 122 L 356 92 L 362 65 L 362 36 L 359 20 L 354 10 L 347 4 L 331 5 L 310 12 Q 288 21 273 35 L 248 25 L 234 22 L 223 22 L 222 21 L 201 22 L 191 24 L 177 28 L 165 34 L 162 34 L 143 20 L 116 8 L 98 4 L 88 4 Z',
'M 90.5 10 L 102.5 11 L 106.5 13 L 117.5 15 L 137.5 24 L 156 37.5 Q 156.8 40.1 153.5 39 Q 143.1 29.4 128.5 24 L 105.5 16 L 93.5 15 L 90 17.5 Q 85.5 23.5 84 32.5 Q 85.5 38 83 39.5 L 83 64.5 L 87 84.5 L 97 107.5 L 97 111.5 L 94 109.5 L 87 93.5 L 81 74.5 L 79 63.5 L 79 38.5 L 84 17.5 L 88.5 11 L 90.5 10 Z',
'M 337.5 10 Q 348.6 8.4 351 15.5 L 353 19.5 L 357 37.5 L 357 61.5 L 353 81.5 L 341 112 Q 336.9 113.7 338 109.5 L 346 92.5 L 351 75.5 L 351 70.5 L 353 64.5 L 353 41.5 L 349 24.5 L 343.5 16 L 337.5 14 L 326.5 16 L 310.5 22 L 283.5 38 L 279 39 L 280.5 36 L 310.5 18 L 320.5 14 L 328.5 13 L 337.5 10 Z',
'M 212.5 27 L 232.5 28 L 242.5 30 L 267.5 39 L 285.5 50 L 306 67 L 326 95.5 L 333 110.5 L 344 145.5 L 344 157.5 L 338.5 172 Q 335.5 159 329 149.5 L 309.5 127 L 302.5 121 L 288.5 114 Q 281.6 110.4 269.5 112 Q 259.9 114.9 254 121.5 L 247 136.5 L 247 147.5 Q 249.9 159.1 258.5 165 L 268.5 169 L 278.5 169 Q 287.5 166 292.5 159 L 304 169.5 L 310 181.5 L 315.5 201 L 317 200.5 Q 298.4 219.4 270.5 229 L 270.5 228 L 275 225.5 L 279 216.5 L 279 199.5 Q 274.5 184 264.5 174 L 249.5 162 L 235.5 156 L 226.5 154 L 206.5 154 Q 185 158 173 171.5 Q 173.8 173.8 171.5 173 Q 163.3 181.3 159 193.5 L 156 204.5 L 156 214.5 L 160 226.5 L 158.5 226 L 155.5 226 L 140.5 218 L 117 199.5 L 118.5 200 Q 121.9 200.9 120 195.5 L 127 177.5 Q 132.3 166.8 140.5 159 L 150.5 167 Q 156.6 170.4 167.5 169 Q 178.3 166.3 184 158.5 Q 190.7 151.7 189 136.5 Q 186 124.5 177.5 118 Q 171.1 109.9 154.5 112 Q 132.3 118.8 119 134.5 Q 104.1 150.1 96 172.5 L 96.5 174 L 93 166.5 L 91 149.5 Q 101.6 98.6 130.5 66 Q 132.8 66.8 132 64.5 L 150.5 48 L 166.5 39 L 187.5 31 L 200.5 28 L 211.5 28 L 212.5 27 Z',
'M 297.5 35 L 296.5 37 L 292.5 39 L 293.5 37 L 297.5 35 Z',
'M 88.5 40 L 89 62.5 L 88 62.5 L 88.5 40 Z',
'M 286.5 41 L 290 46.5 L 286 43.5 L 286.5 41 Z',
'M 347.5 46 L 348 58.5 L 347 58.5 L 347.5 46 Z',
'M 140.5 48 L 136.5 53 L 140.5 48 Z',
'M 297.5 52 L 303.5 59 L 297.5 52 Z',
'M 127.5 61 L 121.5 68 L 127.5 61 Z',
'M 306.5 61 L 314.5 70 L 306.5 61 Z',
'M 157.5 123 L 166 123.5 L 155.5 125 L 155.5 124 L 157.5 123 Z',
'M 263.5 126 L 259.5 131 L 263.5 126 Z',
'M 266.5 129 Q 273 128 274 132.5 Q 275 137 272.5 138 L 273 136.5 Q 274 131 270.5 130 Q 265.5 129 264.5 132 L 266.5 129 Z',
'M 176.5 131 L 180 136.5 L 179 136.5 L 176.5 131 Z',
'M 263.5 133 Q 263.3 137.7 266.5 139 L 268 139.5 L 266.5 140 Q 261.6 138.6 263.5 133 Z',
'M 143.5 139 L 144 147.5 L 143 147.5 L 143.5 139 Z',
'M 176.5 156 L 172.5 161 L 176.5 156 Z',
'M 259.5 156 L 263.5 161 L 259.5 156 Z',
'M 212.5 162 L 222 162.5 L 212.5 163 L 212.5 162 Z',
'M 231.5 175 L 226.5 181 L 231.5 175 Z',
'M 106.5 188 L 111.5 194 L 106.5 188 Z',
'M 326.5 190 L 321.5 196 L 326.5 190 Z',
'M 195.5 194 L 201 201.5 L 196 197.5 L 195.5 194 Z',
'M 239.5 196 L 234.5 202 L 239.5 196 Z',
'M 227.5 200 L 229 200.5 L 222.5 203 L 222.5 202 L 227.5 200 Z',
'M 288.5 227 L 292 229.5 Q 299.7 243.8 304 261.5 L 305 273.5 L 306 274.5 L 306 300.5 L 298 332.5 L 294 344.5 L 291.5 348 L 287.5 349 L 266.5 349 L 249.5 344 L 243 339.5 L 247 319.5 L 249.5 317 L 265 304.5 L 269 295.5 L 264 299.5 L 258.5 306 L 250.5 312 L 231.5 320 Q 226.7 318.8 225.5 321 L 207.5 321 L 187.5 314 Q 175.3 306.7 166.5 296 L 166 297.5 L 176.5 311 L 189 319.5 L 192 341 L 168.5 349 L 151.5 350 L 142 348 L 130 305.5 Q 131.5 300 129 298.5 L 129 271.5 L 133 253.5 L 145 228 L 167.5 236 L 184.5 240 L 196.5 241 L 197.5 242 L 222.5 243 L 223.5 242 L 236.5 242 L 237.5 241 L 254.5 239 L 278.5 232 L 288.5 227 Z',
'M 46.5 236 L 56.5 236 L 79 249 Q 80.5 252.7 77.5 252 L 66 261.5 Q 54.7 272.7 48.5 289 Q 46.3 289.8 47 287.5 L 41 274.5 L 38 257.5 L 38 248.5 Q 39.2 240.7 44.5 237 L 46.5 236 Z',
'M 201.5 236 L 232 236.5 L 201.5 237 L 201.5 236 Z',
'M 106.5 265 L 110.5 265 L 124 271 Q 122.2 276.3 123 285.5 Q 110.8 295.8 105 312.5 Q 102.3 320.8 104 333.5 L 106 342 L 98.5 340 L 74 324 L 74 310.5 L 77 301.5 L 90 280 L 93 278.5 L 101.5 269 L 106.5 265 Z',
'M 312.5 293 Q 319.7 300.3 323 311.5 Q 325.5 319 324 330.5 L 321 338 L 303 333.5 Q 309.5 316.5 311 294.5 L 312.5 293 Z',
'M 122.5 294 L 124 295.5 L 126 313.5 L 132 334.5 L 130.5 336 L 123.5 336 L 111 339 Q 108.6 331.1 110 317.5 Q 113.6 303.1 122.5 294 Z',
'M 194 321 L 207.5 325 L 227.5 325 L 242 321 L 242 325.5 L 234 352.5 L 232 373 L 206.5 373 L 202 370.5 L 202 361.5 L 195 331.5 L 194 321 Z',
];
/** Paths with orange fill (rgb(220,67,4)) */
const orangeIndices = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]);
interface LoadingAnimationProps {
onFinish?: () => void;
}
export default function LoadingAnimation({ onFinish }: LoadingAnimationProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const svg = container.querySelector('svg');
if (!svg) return;
/* All paths except the background rect (index 13) */
const pathElements = Array.from(svg.querySelectorAll('path')).filter(
(_, i) => i !== 13,
);
const getStartPoint = (path: SVGPathElement) => {
const p = path.getPointAtLength(0);
return { x: p.x, y: p.y };
};
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
Math.hypot(a.x - b.x, a.y - b.y);
// Sort paths by proximity
const remaining = [...pathElements];
const ordered: SVGPathElement[] = [];
let current = remaining.shift()!;
ordered.push(current);
while (remaining.length) {
const currPt = getStartPoint(current);
let minIdx = 0;
let minDist = Infinity;
for (let i = 0; i < remaining.length; i++) {
const pt = getStartPoint(remaining[i]);
const d = dist(currPt, pt);
if (d < minDist) {
minDist = d;
minIdx = i;
}
}
current = remaining.splice(minIdx, 1)[0];
ordered.push(current);
}
// Set dash lengths
ordered.forEach((p) => {
const len = p.getTotalLength();
p.style.strokeDasharray = String(len);
p.style.strokeDashoffset = String(len);
});
// Animate
let delay = 0;
ordered.forEach((p) => {
p.style.transition = `stroke-dashoffset 0.4s ease ${delay}s`;
requestAnimationFrame(() => {
p.style.strokeDashoffset = '0';
});
delay += 0.01;
});
// Fill + done
const fillDelay = 1500; // minimum 1.5s total
const animEnd = delay * 1000 + 300;
setTimeout(() => {
pathElements.forEach((p) => {
p.style.transition = 'fill-opacity 0.8s ease';
p.style.fillOpacity = '1';
p.style.stroke = 'none';
});
onFinish?.();
}, Math.max(animEnd, fillDelay));
}, [onFinish]);
return (
<div
ref={containerRef}
className="flex h-screen w-full items-center justify-center bg-white"
>
<svg
viewBox="0 0 400 400"
width="200"
height="200"
xmlns="http://www.w3.org/2000/svg"
>
{pathsData.map((d, i) => {
const isOrange = orangeIndices.has(i);
return (
<path
key={i}
fill={isOrange ? 'rgb(220,67,4)' : 'rgb(6,6,6)'}
stroke={isOrange ? 'rgb(220,67,4)' : 'rgb(6,6,6)'}
strokeWidth="1"
fillOpacity="0"
d={d}
/>
);
})}
</svg>
</div>
);
}