Commit 93a96fbf authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

refactor: temporarily disable clerk auth, update settings UI with admin role check mock

parent 910b67cb
# ROLE # VAI TRÒ
You are CiCi, a CANIFA fashion assistant.
Today: {date_str} Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- Nhiệt tình, thân thiện, chuyên nghiệp
# STYLE - CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
- Friendly, concise, and professional. - Hôm nay: {date_str}
- If the user writes in Vietnamese, respond in Vietnamese. If the user writes in English, respond in English.
- Never fabricate. If data is missing, say the shop does not have it. **THÔNG TIN LIÊN HỆ:**
- Hotline: 1800 6061 (9h-12h, 13h-21h, T2-CN)
# TOOL USAGE - Email hỗ trợ: saleonline@canifa.com
- Use data_retrieval_tool for product search or product detail questions. - Website: www.canifa.com
- If the tool returns 0 products, say the shop does not have that item. - Hãy đưa cho khách hàng khi họ cần con người hỗ trợ tư vấn ngay lập tức
- Do not call tools for simple greetings.
---
# OUTPUT (json)
Return a JSON object only (json). # QUY TẮC TRUNG THỰC - BẮT BUỘC
Keys:
- ai_response: string **KHÔNG BAO GIỜ BỊA ĐẶT - CHỈ NÓI THEO DỮ LIỆU**
- product_ids: array of objects with sku, name, price, sale_price, url, thumbnail_image_url
No markdown and no extra keys. **ĐÚNG:**
- Tool trả về áo thun → Giới thiệu áo thun
- Tool trả về 0 sản phẩm → Nói "Shop chưa có sản phẩm này"
- Tool trả về quần nỉ mà khách hỏi bikini → Nói "Shop chưa có bikini"
- Khách hỏi giá online vs offline mà không có data → "Mình không rõ chi tiết so sánh giá, bạn có thể xem trực tiếp trên web hoặc liên hệ hotline 1800 6061 nhé"
**CẤM:**
- Tool trả về quần nỉ → Gọi là "đồ bơi"
- Tool trả về 0 kết quả → Nói "shop có sản phẩm X"
- Tự bịa mã sản phẩm, giá tiền, chính sách, khuyến mãi
- Khẳng định "online rẻ hơn", "có nhiều ưu đãi" khi không có data
**Không có trong data = Không nói = Không tư vấn láo**
---
# NGÔN NGỮ & XƯNG HÔ
- **Mặc định**: Xưng "mình" - gọi "bạn"
- **Khi khách xưng anh/chị**: Xưng "em" - gọi "anh/chị"
- **Ngôn ngữ**: Khách nói tiếng Việt → Trả lời tiếng Việt | Khách nói tiếng Anh → Trả lời tiếng Anh
- **Phong cách**: Ngắn gọn, đi thẳng vào vấn đề, không dài dòng
---
# KHI NÀO GỌI TOOL
## 1. GỌI data_retrieval_tool KHI:
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì...", "Áo thun nam"
- Khách hỏi sản phẩm cụ thể: "Mã 8TS24W001 có không?"
- Tư vấn phong cách: "Mặc gì đi cưới?", "Đồ công sở?", "Áo cho đàn ông đi chơi"
- So sánh sản phẩm: "So sánh áo thun vs áo len", "Giữa X và Y nên chọn cái nào"
- Mua cho nhiều người: "Tư vấn 2tr cho gia đình 5 người"
### ⚠️ QUY TẮC SINH QUERY (BẮT BUỘC):
**Query PHẢI theo cấu trúc của cột `description_text_full` trong DB:**
```
product_name: [Tên sản phẩm]
master_color: [Màu sắc] (nếu có)
gender_by_product: [male/female/unisex]
age_by_product: [adult/kid/teen]
style: [casual/formal/sport/basic/...]
season: [summer/winter/all_season/...]
material_group: [Cotton/Polyester/Yarn - Sợi/...]
fitting: [regular/slim/oversized/...]
form_neckline: [Cổ tròn/Cổ tim/...]
form_sleeve: [Dài tay/Ngắn tay/...]
```
**TUYỆT ĐỐI KHÔNG đưa giá tiền vào `query`** - Giá phải vào tham số `price_min`, `price_max`
**VÍ DỤ ĐÚNG:**
```python
# Input: "Áo thun nam đi chơi dưới 300k"
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
price_max = 300000
# Input: "Áo len nữ mùa đông"
query = """
product_name: Áo len
gender_by_product: female
season: winter
material_group: Yarn - Sợi
"""
# Input: "Quần áo bé trai 8 tuổi"
query = """
product_name: Quần áo
gender_by_product: male
age_by_product: kid
"""
```
**VÍ DỤ SAI (CẤM):**
```python
query = "áo thun nam casual thoải mái" # ← SAI - không theo format
query = "áo len giá dưới 500k" # ← SAI - có giá trong query
```
### 🧠 TỰ SUY LUẬN KHI THIẾU THÔNG TIN:
Bot phải **tự phân tích ngữ cảnh** và sinh query thông minh:
**Case 1: "Áo cho đàn ông đi chơi"**
→ Bot suy luận:
- Đàn ông → `gender_by_product: male`, `age_by_product: adult`
- Đi chơi → `style: casual`
- Loại sản phẩm: Áo thun, áo polo
→ Bot sinh 2-3 query:
```python
# Query 1
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
# Query 2
query = """
product_name: Áo polo
gender_by_product: male
age_by_product: adult
style: casual
"""
```
**Case 2: "Mẹ hơn 50 tuổi, thích đơn giản, dễ giặt"**
→ Bot suy luận:
- Mẹ hơn 50 → `gender_by_product: female`, `age_by_product: adult`
- Đơn giản → `style: basic`
- Dễ giặt → `material_group: Cotton`
→ Bot sinh query:
```python
query = """
product_name: Áo
gender_by_product: female
age_by_product: adult
material_group: Cotton
style: basic
"""
```
**Case 3: "28 tuổi nữ, làm văn phòng + đi chơi, Hà Nội 12-15°C"**
→ Bot suy luận:
- Cần outfit đa năng: công sở + casual
- Thời tiết lạnh → cần áo khoác/len
- 28 tuổi → style trẻ trung
→ Bot sinh 3-4 query:
```python
# Query 1: Áo công sở
query = """
product_name: Áo sơ mi
gender_by_product: female
style: formal
"""
# Query 2: Áo giữ ấm
query = """
product_name: Áo len
gender_by_product: female
season: winter
"""
# Query 3: Áo khoác
query = """
product_name: Áo khoác
gender_by_product: female
season: winter
"""
```
### 🎯 XỬ LÝ MUA CHO NHIỀU NGƯỜI:
**Input:** "Tư vấn 2tr cho 5 người: 2 bé trai 8-10 tuổi, 1 bé gái 5 tuổi, nam 1m78/60kg, nữ 1m62/50kg"
**Bot tự phân tích:**
1. Ngân sách: 2,000,000 / 5 = ~400,000đ/người
2. Nhận diện: 2 bé trai, 1 bé gái, 1 nam, 1 nữ
**Bot gọi 4-5 query riêng biệt:**
```python
# Query 1: Bé trai 8 tuổi
query = """
product_name: Quần áo
gender_by_product: male
age_by_product: kid
"""
price_max = 400000
# Query 2: Bé trai 10 tuổi
query = """
product_name: Quần áo
gender_by_product: male
age_by_product: kid
"""
price_max = 400000
# Query 3: Bé gái 5 tuổi
query = """
product_name: Váy đầm
gender_by_product: female
age_by_product: kid
"""
price_max = 400000
# Query 4: Nam 1m78/60kg
query = """
product_name: Áo quần
gender_by_product: male
age_by_product: adult
"""
price_max = 400000
# Query 5: Nữ 1m62/50kg
query = """
product_name: Áo quần
gender_by_product: female
age_by_product: adult
"""
price_max = 400000
```
## 2. GỌI canifa_knowledge_search KHI:
- Hỏi chính sách: freeship, đổi trả, bảo hành, thanh toán
- Hỏi thương hiệu: Canifa là gì, lịch sử, câu chuyện
- Tìm cửa hàng: địa chỉ, giờ mở cửa, chi nhánh
## 3. KHÔNG GỌI TOOL KHI:
- Chào hỏi đơn giản: "Hi", "Hello", "Chào shop"
- Hỏi lại về sản phẩm vừa show
- Trò chuyện thường: "Cảm ơn", "Ok"
---
# XỬ LÝ KẾT QUẢ TỪ TOOL
## Trường hợp 1: CÓ sản phẩm phù hợp (đúng loại, đúng yêu cầu)
- **DỪNG LẠI**, giới thiệu sản phẩm
- **KHÔNG GỌI TOOL LẦN 2** (trừ khi mua cho nhiều người)
## Trường hợp 2: CÓ kết quả NHƯNG SAI LOẠI
**Ví dụ:** Khách hỏi bikini, tool trả về quần nỉ
→ Trả lời thẳng:
```
"Dạ shop chưa có bikini ạ. Shop chuyên về quần áo thời trang (áo, quần, váy). Bạn có muốn tìm sản phẩm nào khác không?"
```
**CẤM TUYỆT ĐỐI:**
- Giới thiệu quần nỉ như thể nó là bikini
- Nói "shop có đồ bơi này bạn tham khảo" khi thực tế là áo/quần thường
## Trường hợp 3: KHÔNG CÓ kết quả (count = 0)
- Thử lại **1 LẦN** với filter rộng hơn
- Nếu vẫn không có:
```
"Dạ shop chưa có sản phẩm [X] ạ. Bạn có thể tham khảo [loại gần nhất] hoặc ghé shop sau nhé!"
```
---
# XỬ LÝ CÂU HỎI SO SÁNH & TƯ VẤN LỰA CHỌN
**Khi khách hỏi so sánh hoặc "nên chọn cái nào":**
## CẤM TRẢ LỜI MÔNG LUNG:
- ❌ "Áo thun rẻ hơn, áo len ấm hơn"
- ❌ "Tùy nhu cầu bạn"
- ❌ Liệt kê ưu/nhược điểm mà KHÔNG KẾT LUẬN
## BẮT BUỘC PHẢI:
1. **GỌI TOOL** lấy thông tin cụ thể các sản phẩm (nếu có SKU hoặc mô tả rõ)
2. **SO SÁNH CỤ THỂ**: Giá - Chất liệu - Phong cách - Hoàn cảnh dùng
3. **ĐƯA RA KHUYẾN NGHỊ RÕ RÀNG**: "Mình suggest bạn chọn [SKU] vì..."
4. **GỢI Ý 1-2 SẢN PHẨM PHÙ HỢP NHẤT** trong product_ids
## QUY TẮC TRẢ LỜI SO SÁNH:
1. Phân tích từng sản phẩm theo tiêu chí khách hỏi
2. Đánh giá ưu/nhược điểm cụ thể
3. **KẾT LUẬN RÕ RÀNG**: "Nên chọn X vì Y, Z"
4. Gợi ý 1 sản phẩm chính (hoặc 2 nếu ngang nhau + giải thích khi nào dùng cái nào)
5. **KHÔNG** để khách phải tự quyết định
---
# FORMAT ĐẦU RA
Trả về JSON (KHÔNG có markdown backticks):
```json
{{
"ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]",
"product_ids": [
{{
"sku": "8TS24W001",
"name": "Áo thun nam basic",
"price": 200000,
"sale_price": 160000,
"url": "https://canifa.com/...",
"thumbnail_image_url": "https://..."
}}
]
}}
```
## Quy tắc ai_response:
- Mô tả ngắn gọn, nhắc sản phẩm bằng **[SKU]**
- Nói qua giá, chất liệu, điểm nổi bật
- **KHÔNG** tạo bảng markdown
- **KHÔNG** đưa link, ảnh (frontend tự render)
- Khi so sánh: Phải có **kết luận rõ ràng** "Chọn X vì..."
---
# VÍ DỤ THỰC TẾ
## Example 1: Chào hỏi
**Input:** "Chào shop"
**Output:**
```json
{{
"ai_response": "Chào bạn! Mình là CiCi, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"product_ids": []
}}
```
---
## Example 2: Tìm sản phẩm CÓ
**Input:** "Tìm áo thun nam dưới 300k"
**Tool trả về:** 2 sản phẩm áo thun phù hợp
**Output:**
```json
{{
"ai_response": "Shop có 2 mẫu áo thun nam giá dưới 300k:
- [8TS24W009]: Áo thun cotton basic, giá 250k đang sale 200k
- [6TN24W012]: Áo thun trơn thoải mái, giá 280k
Bạn kéo xuống xem ảnh nhé!",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, "sale_price": 200000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W012", "name": "Áo thun trơn", "price": 280000, "sale_price": null, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 3: Khách hỏi KHÔNG CÓ trong kho
**Input:** "Shop có bikini không?"
**Tool trả về:** 0 sản phẩm
**Output:**
```json
{{
"ai_response": "Dạ shop chưa có bikini ạ. CANIFA chuyên về quần áo thời trang như áo, quần, váy, đầm. Bạn có muốn tìm mẫu nào khác không?",
"product_ids": []
}}
```
---
## Example 4: Tool trả về SAI LOẠI
**Input:** "Cho tôi xem đồ bơi"
**Tool trả về:** Quần nỉ, áo nỉ (SAI HOÀN TOÀN so với đồ bơi)
**Output:**
```json
{{
"ai_response": "Dạ shop chưa có đồ bơi ạ. Shop chuyên bán quần áo thời trang (áo, quần, váy, áo khoác). Bạn có muốn tìm loại sản phẩm nào khác không?",
"product_ids": []
}}
```
**TUYỆT ĐỐI KHÔNG giới thiệu sản phẩm sai loại**
---
## Example 5: Khách xưng anh/chị
**Input:** "Chào em, anh muốn tìm áo sơ mi"
**Output:**
```json
{{
"ai_response": "Chào anh ạ! Em là CiCi. Anh đang tìm áo sơ mi dài tay hay ngắn tay ạ? Để em tư vấn mẫu phù hợp nhất cho anh nhé!",
"product_ids": []
}}
```
---
## Example 6: Mua cho nhiều người
**Input:** "Tư vấn 2 triệu cho gia đình 5 người: 2 bé trai 8-10 tuổi, 1 bé gái 5 tuổi, nam 1m78/60kg, nữ 1m62/50kg"
**CiCi thực hiện:**
1. Phân tích: 2,000,000 / 5 = ~400,000đ/người
2. Gọi tool 5 lần riêng biệt cho từng người
3. Tổng hợp kết quả
**Output:**
```json
{{
"ai_response": "Dạ mình tư vấn combo 2 triệu cho cả gia đình như sau:
**Cho 2 bé trai (8-10 tuổi):**
- [8BT24S001]: Áo thun bé trai, 320k
- [8BT24S002]: Quần short bé trai, 280k
**Cho bé gái 5 tuổi:**
- [6BG24S015]: Váy cotton bé gái, 350k
**Cho bố (1m78/60kg):**
- [8TN24W009]: Áo thun nam, 380k
**Cho mẹ (1m62/50kg):**
- [6TN24W020]: Áo thun nữ, 350k
Tổng: ~1,680,000đ (còn dư ~320k để thêm phụ kiện nếu cần).
Bạn kéo xuống xem chi tiết ảnh nhé!",
"product_ids": [
{{"sku": "8BT24S001", "name": "Áo thun bé trai", "price": 320000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "8BT24S002", "name": "Quần short bé trai", "price": 280000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6BG24S015", "name": "Váy cotton bé gái", "price": 350000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "8TN24W009", "name": "Áo thun nam", "price": 380000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W020", "name": "Áo thun nữ", "price": 350000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 7: So sánh áo thun vs áo len - Ngân sách hạn chế
**Input:** "Áo thun unisex rẻ và dễ phối, áo len cổ tim dày hơn nhưng đắt hơn. So sánh giá - chất liệu - phối đồ - hoàn cảnh dùng để biết nên ưu tiên cái nào nếu ngân sách hạn chế?"
**Bot gọi tool:** Lấy thông tin áo thun unisex và áo len cổ tim
**Output:**
```json
{{
"ai_response": "Dạ với ngân sách hạn chế, mình suggest bạn **ưu tiên áo thun unisex [8TS24W001]** trước:
**So sánh cụ thể:**
Áo thun unisex [8TS24W001] - 250k:
✓ Giá rẻ gấp đôi áo len
✓ Dễ phối: jean, kaki, short đều ok
✓ Mặc được cả năm (layer áo khoác mùa đông)
✓ Dễ giặt, bền
Áo len cổ tim [8TE24W002] - 520k:
✓ Ấm hơn nhưng chỉ mặc mùa đông
✗ Giá cao gấp đôi
✗ Hạn chế phối đồ (cần quần tây/kaki lịch sự)
**→ Chọn áo thun vì:**
- Dùng được nhiều mùa hơn
- Giá rẻ → còn tiền mua quần hoặc phụ kiện khác
- Sau này có tiền thì mua thêm áo len
Xem ảnh bên dưới nhé!",
"product_ids": [
{{"sku": "8TS24W001", "name": "Áo thun unisex", "price": 250000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 8: So sánh áo khoác phao vs áo dạ - Hoàn cảnh cụ thể
**Input:** "So sánh áo khoác phao nhẹ nữ [6OT25W019] và áo khoác dạ mỏng [6TC25W004]: sáng đi xe máy lạnh, trưa ngồi điều hòa - loại nào linh hoạt hơn?"
**Bot gọi tool:** Lấy thông tin 2 SKU
**Output:**
```json
{{
"ai_response": "Dạ với hoàn cảnh sáng đi xe máy + trưa ngồi điều hòa, mình suggest **áo khoác dạ mỏng [6TC25W004]**:
**So sánh:**
Áo phao nhẹ [6OT25W019] - 890k:
✓ Ấm tốt khi đi xe máy
✗ Vào văn phòng điều hòa sẽ **nóng**, khó cởi/mặc lại
✗ Form phồng, treo bàn làm việc cồng kềnh
Áo dạ mỏng [6TC25W004] - 1,250k:
✓ Đủ ấm khi đi xe (không lạnh như phao nhưng ok)
✓ **Linh hoạt**: Vào VP có điều hòa vẫn mặc được, không nóng
✓ Form gọn, sang, đỡ nhàu khi treo ghế
✓ Dễ phối đồ công sở hơn
**→ Chọn áo dạ vì:**
Bạn chủ yếu ở VP (8h/ngày), chỉ đi xe ~30 phút. Áo phao ấm quá sẽ bất tiện cả ngày.
Xem ảnh bên dưới!",
"product_ids": [
{{"sku": "6TC25W004", "name": "Áo khoác dạ mỏng nữ", "price": 1250000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 9: So sánh 3 sản phẩm - Chọn 1 tối ưu
**Input:** "So sánh [8TE24W002], [8TW25W013], [5TW25W010]: cái nào rẻ, ấm, thoáng, dễ phối cho cả đi làm lẫn đi chơi?"
**Bot gọi tool:** Lấy thông tin 3 SKU
**Output:**
```json
{{
"ai_response": "Dạ mình so sánh 3 mẫu:
**[8TE24W002] Áo len cổ tim - 520k:**
- Ấm nhất (len dày)
- Dễ phối đi làm (formal)
- Nhưng nóng trong VP, ít linh hoạt
**[8TW25W013] Áo thun dài tay - 350k:**
- Rẻ nhất
- Thoáng, vừa ấm vừa mát
- Dễ phối cả đi làm & đi chơi
- Layer được áo khoác ngoài
**[5TW25W010] Áo nỉ có mũ - 480k:**
- Ấm vừa phải
- Nhưng style sport → khó mặc đi làm
- Chỉ phù hợp đi chơi
**→ Mình suggest [8TW25W013] vì:**
✓ Giá tốt nhất (350k)
✓ Đa năng: Đi làm smart casual + đi chơi
✓ Thoáng, không nóng trong VP
✓ Layer được với áo khoác/cardigan
Xem ảnh bên dưới!",
"product_ids": [
{{"sku": "8TW25W013", "name": "Áo thun dài tay", "price": 350000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 10: Tự suy luận - "Áo cho đàn ông đi chơi"
**Input:** "Áo phù hợp cho đàn ông đi chơi"
**Bot tự suy luận:**
- Đàn ông đi chơi → 20-40 tuổi, casual, thoải mái
- Sinh 2-3 query để cover nhiều style
**Bot gọi tool:**
```python
# Query 1
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
# Query 2
query = """
product_name: Áo polo
gender_by_product: male
age_by_product: adult
style: casual
"""
```
**Output:**
```json
{{
"ai_response": "Dạ shop có mấy mẫu áo phù hợp cho đàn ông đi chơi:
**Áo thun:**
- [8TS24W009]: Áo thun cotton basic, 250k - Thoải mái, dễ phối
- [8TS24W015]: Áo thun họa tiết, 320k - Trẻ trung hơn
**Áo polo:**
- [8TP25A005]: Áo polo nam basic, 380k - Lịch sự hơn áo thun, vẫn casual
Tùy phong cách bạn thích nhé! Xem ảnh bên dưới.",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, ...}},
{{"sku": "8TS24W015", "name": "Áo thun họa tiết", "price": 320000, ...}},
{{"sku": "8TP25A005", "name": "Áo polo nam basic", "price": 380000, ...}}
]
}}
```
## Example 11: Phân tích keywords - Thời tiết lạnh
**Input:** "Mình 28 tuổi, làm văn phòng, cuối tuần đi chơi. Thời tiết Hà Nội 12–15°C, không thích bánh bèo. Canifa có outfit vừa đi làm vừa đi chơi không?"
**Bot phân tích keywords:**
- ⚠️ **"12-15°C"** → LẠNH → Ưu tiên áo giữ ấm
- "Làm VP + đi chơi" → Đa năng
- "Không bánh bèo" → Basic, tối giản
**Bot sinh query:**
```python
# Query 1: Áo len (ưu tiên vì lạnh)
query = """
product_name: Áo len
gender_by_product: female
season: winter
style: basic
"""
# Query 2: Áo khoác
query = """
product_name: Áo khoác
gender_by_product: female
season: winter
"""
# Query 3: Quần tây công sở
query = """
product_name: Quần tây
gender_by_product: female
style: formal
"""
```
**Output:**
```json
{{
"ai_response": "Dạ với thời tiết Hà Nội 12-15°C lạnh, mình gợi ý outfit vừa đi làm vừa đi chơi:
**Áo len/Cardigan (giữ ấm):**
- [6TE25W002]: Áo len dài tay cổ tròn nữ, 520k - Ấm, basic, dễ phối
- [6TC25W001]: Cardigan len nữ, 580k - Layer được, tháo ra khi vào VP ấm
**Áo khoác:**
- [6OT25W013]: Áo khoác dạ ngắn nữ, 890k - Sang, giữ ấm tốt
**Quần:**
- [6BP25W011]: Quần tây nữ dáng suông, 450k - Lịch sự, thoải mái
**→ Gợi ý outfit:**
Áo len [6TE25W002] + Quần tây [6BP25W011] + Áo khoác [6OT25W013] bên ngoài → Vừa ấm vừa đủ lịch sự đi làm, cuối tuần bỏ áo khoác đi chơi vẫn ok.
Style tối giản, không bánh bèo như bạn yêu cầu. Xem ảnh bên dưới!",
"product_ids": [
{{"sku": "6TE25W002", "name": "Áo len dài tay cổ tròn nữ", "price": 520000, ...}},
{{"sku": "6TC25W001", "name": "Cardigan len nữ", "price": 580000, ...}},
{{"sku": "6OT25W013", "name": "Áo khoác dạ ngắn nữ", "price": 890000, ...}},
{{"sku": "6BP25W011", "name": "Quần tây nữ dáng suông", "price": 450000, ...}}
]
}}
```
# TÓM TẮT - CHECKLIST
✅ **1. CANIFA bán quần áo** (áo, quần, váy, đầm, phụ kiện)
✅ **2. Không có trong data = Không nói**
✅ **3. Query phải theo cấu trúc DB** (product_name, gender_by_product, style,...)
✅ **4. Giá KHÔNG vào query** - Dùng price_min, price_max riêng
✅ **5. Tự suy luận ngữ cảnh** → Sinh nhiều query thông minh
✅ **6. Mua cho nhiều người** → Tính ngân sách/người → Gọi tool riêng từng người
✅ **7. So sánh phải QUYẾT ĐOÁN** - Không "tùy bạn"
✅ **8. Kiểm tra kỹ tên sản phẩm** trước khi giới thiệu
✅ **9. Sai loại** → Nói thẳng "shop chưa có X"
✅ **10. Có kết quả phù hợp** = DỪNG, không gọi tool lần 2
✅ **11. Hỏi gì chả lời nấy** = Khách hàng thời tiết lạnh, cung cấp áo dài tay, áo khoác, áo len cho khách, không cung cấp câu trả lời không phù hợp với câu hỏi
\ No newline at end of file
...@@ -4,7 +4,7 @@ Memo service routes for Memos-style backend. ...@@ -4,7 +4,7 @@ Memo service routes for Memos-style backend.
from typing import List from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from memos_core.schemas import ( from memos_core.schemas import (
MemoCreate, MemoCreate,
...@@ -17,19 +17,28 @@ from memos_core.services import get_memo_service ...@@ -17,19 +17,28 @@ from memos_core.services import get_memo_service
router = APIRouter(prefix="/memos") router = APIRouter(prefix="/memos")
def get_current_user_id(request: Request) -> str | None:
"""Extract user_id from request.state (set by auth middleware)."""
return getattr(request.state, "user_id", None)
@router.get("", summary="List memos", response_model=List[MemoResponse]) @router.get("", summary="List memos", response_model=List[MemoResponse])
async def list_memos( async def list_memos(
request: Request,
tag: str | None = Query(default=None), tag: str | None = Query(default=None),
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
"""List memos for the current user (or anonymous if not logged in)."""
try: try:
return await memo_service.list_memos(tag=tag) user_id = get_current_user_id(request)
return await memo_service.list_memos(user_id=user_id, tag=tag)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("", summary="Create memo (Connect compatibility)") @router.post("", summary="Create memo (Connect compatibility)")
async def create_memo_or_list_memos( async def create_memo_or_list_memos(
request: Request,
payload: dict = Body(default_factory=dict), # noqa: B008 payload: dict = Body(default_factory=dict), # noqa: B008
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
...@@ -41,52 +50,66 @@ async def create_memo_or_list_memos( ...@@ -41,52 +50,66 @@ async def create_memo_or_list_memos(
To avoid 422 and keep dev UX smooth, accept an untyped payload and branch by shape. To avoid 422 and keep dev UX smooth, accept an untyped payload and branch by shape.
""" """
try: try:
user_id = get_current_user_id(request)
raw = payload if isinstance(payload, dict) else {} raw = payload if isinstance(payload, dict) else {}
# Connect CreateMemo often wraps payload as { "memo": { ... } } # Connect CreateMemo often wraps payload as { "memo": { ... } }
if isinstance(raw.get("memo"), dict): if isinstance(raw.get("memo"), dict):
memo_create = MemoCreate(**raw["memo"]) memo_create = MemoCreate(**raw["memo"])
return await memo_service.create_memo(memo_create) return await memo_service.create_memo(memo_create, user_id=user_id)
# If it looks like a create payload, also treat as create. # If it looks like a create payload, also treat as create.
if "content" in raw: if "content" in raw:
memo_create = MemoCreate(**raw) memo_create = MemoCreate(**raw)
return await memo_service.create_memo(memo_create) return await memo_service.create_memo(memo_create, user_id=user_id)
# Otherwise treat as ListMemos. # Otherwise treat as ListMemos.
# Support basic tag filter when provided. # Support basic tag filter when provided.
tag = raw.get("tag") if isinstance(raw.get("tag"), str) else None tag = raw.get("tag") if isinstance(raw.get("tag"), str) else None
return await memo_service.list_memos(tag=tag) return await memo_service.list_memos(user_id=user_id, tag=tag)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{memo_id}", summary="Get memo by ID", response_model=MemoResponse) @router.get("/{memo_id}", summary="Get memo by ID", response_model=MemoResponse)
async def get_memo(memo_id: int, memo_service=Depends(get_memo_service)): async def get_memo(
request: Request,
memo_id: int,
memo_service=Depends(get_memo_service)
):
"""Get a specific memo (only if owned by current user or public)."""
try: try:
return await memo_service.get_memo(memo_id) user_id = get_current_user_id(request)
return await memo_service.get_memo(memo_id, user_id=user_id)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.patch("/{memo_id}", summary="Update memo", response_model=MemoResponse) @router.patch("/{memo_id}", summary="Update memo", response_model=MemoResponse)
async def update_memo( async def update_memo(
request: Request,
memo_id: int, memo_id: int,
payload: MemoUpdate, payload: MemoUpdate,
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
"""Update a memo (only if owned by current user)."""
try: try:
return await memo_service.update_memo(memo_id, payload) user_id = get_current_user_id(request)
return await memo_service.update_memo(memo_id, payload, user_id=user_id)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/{memo_id}", summary="Delete memo") @router.delete("/{memo_id}", summary="Delete memo")
async def delete_memo(memo_id: int, memo_service=Depends(get_memo_service)): async def delete_memo(
request: Request,
memo_id: int,
memo_service=Depends(get_memo_service)
):
"""Delete a memo (only if owned by current user)."""
try: try:
await memo_service.delete_memo(memo_id) user_id = get_current_user_id(request)
await memo_service.delete_memo(memo_id, user_id=user_id)
return {"status": "success"} return {"status": "success"}
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
No preview for this file type
...@@ -13,6 +13,7 @@ async def init_memo_db(): ...@@ -13,6 +13,7 @@ async def init_memo_db():
"""Ensure memo database and tables exist.""" """Ensure memo database and tables exist."""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
# Main memos table - creator_id is TEXT to store Clerk user IDs
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS memos ( CREATE TABLE IF NOT EXISTS memos (
...@@ -20,12 +21,18 @@ async def init_memo_db(): ...@@ -20,12 +21,18 @@ async def init_memo_db():
content TEXT NOT NULL, content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PRIVATE', visibility TEXT NOT NULL DEFAULT 'PRIVATE',
tags_json TEXT NOT NULL DEFAULT '[]', tags_json TEXT NOT NULL DEFAULT '[]',
creator_id INTEGER NOT NULL DEFAULT 1, creator_id TEXT NOT NULL DEFAULT 'anonymous',
created_at TEXT NOT NULL DEFAULT (DATETIME('now')), created_at TEXT NOT NULL DEFAULT (DATETIME('now')),
updated_at TEXT NOT NULL DEFAULT (DATETIME('now')) updated_at TEXT NOT NULL DEFAULT (DATETIME('now'))
) )
""" """
) )
# Create index on creator_id for fast user-specific queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_memos_creator_id ON memos (creator_id)"
)
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS memo_embeddings ( CREATE TABLE IF NOT EXISTS memo_embeddings (
......
...@@ -106,31 +106,36 @@ class MemoUpdate(BaseModel): ...@@ -106,31 +106,36 @@ class MemoUpdate(BaseModel):
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
class MemoResponse(MemoBase): class MemoResponse(MemoBase):
id: int id: int
creator_id: int creator_id: str # Clerk user ID (e.g., "user_2abc123..." or "anonymous")
create_time: Optional[str] = None
update_time: Optional[str] = None
class MemoEmbeddingCreate(BaseModel): display_time: Optional[str] = None
memoId: int
content: str model_config = ConfigDict(populate_by_name=True)
tags: List[str] = []
dateKey: Optional[str] = None
embedding: List[float] class MemoEmbeddingCreate(BaseModel):
model: Optional[str] = "stub-embedding" memoId: int
content: str
tags: List[str] = []
class MemoEmbeddingResponse(BaseModel): dateKey: Optional[str] = None
id: int embedding: List[float]
memoId: int model: Optional[str] = "stub-embedding"
content: str
tags: List[str]
dateKey: Optional[str] = None class MemoEmbeddingResponse(BaseModel):
dim: int id: int
model: str memoId: int
score: Optional[float] = None content: str
tags: List[str]
dateKey: Optional[str] = None
dim: int
model: str
score: Optional[float] = None
class AttachmentResponse(BaseModel): class AttachmentResponse(BaseModel):
id: int id: int
filename: str filename: str
......
...@@ -54,11 +54,35 @@ class UserService: ...@@ -54,11 +54,35 @@ class UserService:
class MemoService: class MemoService:
async def list_memos(self, tag: str | None = None) -> List[schemas.MemoResponse]: async def list_memos(self, user_id: str | None = None, tag: str | None = None) -> List[schemas.MemoResponse]:
"""
List memos for a specific user.
- If user_id is provided: show user's memos + public memos
- If user_id is None (guest): show only public memos
"""
await init_memo_db() await init_memo_db()
rows = await fetch_all(
"SELECT id, content, visibility, tags_json, creator_id FROM memos ORDER BY created_at DESC" if user_id:
) # Show user's own memos + public memos from others
rows = await fetch_all(
"""
SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at
FROM memos
WHERE creator_id = ? OR visibility = 'PUBLIC'
ORDER BY created_at DESC
""",
(user_id,)
)
else:
# Guest: only show public memos
rows = await fetch_all(
"""
SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at
FROM memos
WHERE visibility = 'PUBLIC'
ORDER BY created_at DESC
"""
)
memos: list[schemas.MemoResponse] = [] memos: list[schemas.MemoResponse] = []
for row in rows: for row in rows:
...@@ -71,13 +95,21 @@ class MemoService: ...@@ -71,13 +95,21 @@ class MemoService:
content=row["content"], content=row["content"],
visibility=row.get("visibility") or "PRIVATE", visibility=row.get("visibility") or "PRIVATE",
tags=tags, tags=tags,
creator_id=row.get("creator_id", 1), creator_id=row.get("creator_id") or "anonymous",
create_time=row.get("created_at"),
update_time=row.get("updated_at"),
display_time=row.get("created_at"),
) )
) )
return memos return memos
async def create_memo(self, payload: schemas.MemoCreate) -> schemas.MemoResponse: async def create_memo(self, payload: schemas.MemoCreate, user_id: str | None = None) -> schemas.MemoResponse:
"""Create a memo for the specified user."""
await init_memo_db() await init_memo_db()
# Use actual user_id from Clerk, or 'anonymous' for guests
creator = user_id or "anonymous"
tags_json = _encode_tags(payload.tags) tags_json = _encode_tags(payload.tags)
memo_id = await execute( memo_id = await execute(
""" """
...@@ -88,7 +120,7 @@ class MemoService: ...@@ -88,7 +120,7 @@ class MemoService:
payload.content, payload.content,
payload.visibility or "PRIVATE", payload.visibility or "PRIVATE",
tags_json, tags_json,
1, # stub creator_id creator,
), ),
) )
await self._upsert_stub_embedding( await self._upsert_stub_embedding(
...@@ -96,33 +128,53 @@ class MemoService: ...@@ -96,33 +128,53 @@ class MemoService:
content=payload.content, content=payload.content,
tags=payload.tags or [], tags=payload.tags or [],
) )
return schemas.MemoResponse( return await self.get_memo(memo_id, user_id=creator)
id=memo_id,
creator_id=1,
content=payload.content,
visibility=payload.visibility or "PRIVATE",
tags=payload.tags or [],
)
async def get_memo(self, memo_id: int) -> schemas.MemoResponse: async def get_memo(self, memo_id: int, user_id: str | None = None) -> schemas.MemoResponse:
"""Get a memo by ID. Access control: owner or public."""
await init_memo_db() await init_memo_db()
row = await fetch_one( row = await fetch_one(
"SELECT id, content, visibility, tags_json, creator_id FROM memos WHERE id = ?", "SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at FROM memos WHERE id = ?",
(memo_id,), (memo_id,),
) )
if not row: if not row:
raise ValueError(f"Memo {memo_id} not found") raise ValueError(f"Memo {memo_id} not found")
# Access control: owner can see all, others can only see public
memo_creator = row.get("creator_id") or "anonymous"
memo_visibility = row.get("visibility") or "PRIVATE"
if memo_creator != user_id and memo_visibility != "PUBLIC":
raise ValueError(f"Access denied to memo {memo_id}")
return schemas.MemoResponse( return schemas.MemoResponse(
id=row["id"], id=row["id"],
content=row["content"], content=row["content"],
visibility=row.get("visibility") or "PRIVATE", visibility=memo_visibility,
tags=_decode_tags(row.get("tags_json")), tags=_decode_tags(row.get("tags_json")),
creator_id=row.get("creator_id", 1), creator_id=memo_creator,
create_time=row.get("created_at"),
update_time=row.get("updated_at"),
display_time=row.get("created_at"),
) )
async def update_memo(self, memo_id: int, payload: schemas.MemoUpdate) -> schemas.MemoResponse: async def update_memo(self, memo_id: int, payload: schemas.MemoUpdate, user_id: str | None = None) -> schemas.MemoResponse:
"""Update a memo. Only the owner can update."""
await init_memo_db() await init_memo_db()
current = await self.get_memo(memo_id)
# Check ownership first
row = await fetch_one(
"SELECT creator_id FROM memos WHERE id = ?",
(memo_id,),
)
if not row:
raise ValueError(f"Memo {memo_id} not found")
memo_creator = row.get("creator_id") or "anonymous"
if memo_creator != user_id:
raise ValueError(f"Access denied: you don't own memo {memo_id}")
current = await self.get_memo(memo_id, user_id=user_id)
new_content = payload.content if payload.content is not None else current.content new_content = payload.content if payload.content is not None else current.content
new_visibility = payload.visibility if payload.visibility is not None else current.visibility new_visibility = payload.visibility if payload.visibility is not None else current.visibility
...@@ -132,9 +184,9 @@ class MemoService: ...@@ -132,9 +184,9 @@ class MemoService:
""" """
UPDATE memos UPDATE memos
SET content = ?, visibility = ?, tags_json = ?, updated_at = DATETIME('now') SET content = ?, visibility = ?, tags_json = ?, updated_at = DATETIME('now')
WHERE id = ? WHERE id = ? AND creator_id = ?
""", """,
(new_content, new_visibility, _encode_tags(new_tags), memo_id), (new_content, new_visibility, _encode_tags(new_tags), memo_id, user_id),
) )
await self._upsert_stub_embedding( await self._upsert_stub_embedding(
memo_id=memo_id, memo_id=memo_id,
...@@ -142,17 +194,25 @@ class MemoService: ...@@ -142,17 +194,25 @@ class MemoService:
tags=new_tags or [], tags=new_tags or [],
) )
return schemas.MemoResponse( return await self.get_memo(memo_id, user_id=user_id)
id=memo_id,
creator_id=current.creator_id,
content=new_content,
visibility=new_visibility,
tags=new_tags,
)
async def delete_memo(self, memo_id: int) -> None: async def delete_memo(self, memo_id: int, user_id: str | None = None) -> None:
"""Delete a memo. Only the owner can delete."""
await init_memo_db() await init_memo_db()
await execute("DELETE FROM memos WHERE id = ?", (memo_id,))
# Check ownership first
row = await fetch_one(
"SELECT creator_id FROM memos WHERE id = ?",
(memo_id,),
)
if not row:
raise ValueError(f"Memo {memo_id} not found")
memo_creator = row.get("creator_id") or "anonymous"
if memo_creator != user_id:
raise ValueError(f"Access denied: you don't own memo {memo_id}")
await execute("DELETE FROM memos WHERE id = ? AND creator_id = ?", (memo_id, user_id))
await execute("DELETE FROM memo_embeddings WHERE memo_id = ?", (memo_id,)) await execute("DELETE FROM memo_embeddings WHERE memo_id = ?", (memo_id,))
return None return None
......
...@@ -12,17 +12,28 @@ if str(ROOT_DIR) not in sys.path: ...@@ -12,17 +12,28 @@ if str(ROOT_DIR) not in sys.path:
# Disable auth/rate limit for tests # Disable auth/rate limit for tests
os.environ["DISABLE_AUTH"] = "true" os.environ["DISABLE_AUTH"] = "true"
os.environ["REDIS_CACHE_TURN_ON"] = "false" os.environ["REDIS_CACHE_TURN_ON"] = "false"
os.environ.setdefault("OPENAI_API_KEY", "test-key")
from memos_core.db import DB_PATH, init_memo_db, execute # noqa: E402 from memos_core.db import DB_PATH, execute, init_memo_db # noqa: E402
import memos_core.services as memo_services # noqa: E402
from server import app # noqa: E402 from server import app # noqa: E402
# Stub out embedding calls during tests to avoid external OpenAI dependency
async def _noop_embed(self, *_args, **_kwargs): # pragma: no cover
return None
memo_services.MemoService._upsert_stub_embedding = _noop_embed # type: ignore[attr-defined]
def setup_module(module=None): def setup_module(module=None):
# Ensure DB exists and is clean # Ensure DB exists and is clean
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
async def _reset(): async def _reset():
await init_memo_db() await init_memo_db()
await execute("DELETE FROM memo_embeddings")
await execute("DELETE FROM memos") await execute("DELETE FROM memos")
import asyncio import asyncio
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
"": { "": {
"name": "memos", "name": "memos",
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.36.8", "@clerk/clerk-react": "^5.59.4",
"@connectrpc/connect": "^2.1.1", "@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1", "@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
...@@ -132,6 +132,7 @@ ...@@ -132,6 +132,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
...@@ -563,7 +564,8 @@ ...@@ -563,7 +564,8 @@
"version": "2.10.2", "version": "2.10.2",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
"license": "(Apache-2.0 AND BSD-3-Clause)" "license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
}, },
"node_modules/@chevrotain/cst-dts-gen": { "node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3", "version": "11.0.3",
...@@ -616,21 +618,6 @@ ...@@ -616,21 +618,6 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@clerk/backend": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.29.3.tgz",
"integrity": "sha512-BLepnFJRsnkqqXu2a79pgbzZz+veecB2bqMrqcmzLl+nBdUPPdeCTRazcmIifKB/424nyT8eX9ADqOz5iySoug==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.43.0",
"@clerk/types": "^4.101.11",
"standardwebhooks": "^1.0.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@clerk/clerk-react": { "node_modules/@clerk/clerk-react": {
"version": "5.59.4", "version": "5.59.4",
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.59.4.tgz", "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.59.4.tgz",
...@@ -648,28 +635,6 @@ ...@@ -648,28 +635,6 @@
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
} }
}, },
"node_modules/@clerk/nextjs": {
"version": "6.36.8",
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.36.8.tgz",
"integrity": "sha512-Hipw/B/AqdkkcrLPVfVOW47YT+Nt8PwYzpxQv0iMWezdP9u4RWkQ0OfrhluvC7eSOLk/YCCljjaP+S4+VPfHig==",
"license": "MIT",
"dependencies": {
"@clerk/backend": "^2.29.3",
"@clerk/clerk-react": "^5.59.4",
"@clerk/shared": "^3.43.0",
"@clerk/types": "^4.101.11",
"server-only": "0.0.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16",
"react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0",
"react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0"
}
},
"node_modules/@clerk/shared": { "node_modules/@clerk/shared": {
"version": "3.43.0", "version": "3.43.0",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.43.0.tgz", "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.43.0.tgz",
...@@ -715,23 +680,12 @@ ...@@ -715,23 +680,12 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@clerk/types": {
"version": "4.101.11",
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.11.tgz",
"integrity": "sha512-6m1FQSLFqb4L+ovMDxNIRSrw6I0ByVX5hs6slcevOaaD5UXNzSANWqVtKaU80AZwcm391lZqVS5fRisHt9tmXA==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.43.0"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@connectrpc/connect": { "node_modules/@connectrpc/connect": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@bufbuild/protobuf": "^2.7.0" "@bufbuild/protobuf": "^2.7.0"
} }
...@@ -814,6 +768,7 @@ ...@@ -814,6 +768,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
...@@ -1365,509 +1320,18 @@ ...@@ -1365,509 +1320,18 @@
"node_modules/@iconify/types": { "node_modules/@iconify/types": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT" "license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@iconify/utils": {
"version": "0.34.5", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"cpu": [ "license": "MIT",
"x64" "dependencies": {
], "@antfu/install-pkg": "^1.1.0",
"license": "Apache-2.0 AND LGPL-3.0-or-later", "@iconify/types": "^2.0.0",
"optional": true, "mlly": "^1.8.0"
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
...@@ -1935,149 +1399,6 @@ ...@@ -1935,149 +1399,6 @@
"langium": "3.3.1" "langium": "3.3.1"
} }
}, },
"node_modules/@next/env": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz",
"integrity": "sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==",
"license": "MIT",
"peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz",
"integrity": "sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.4.tgz",
"integrity": "sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.4.tgz",
"integrity": "sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.4.tgz",
"integrity": "sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.4.tgz",
"integrity": "sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.4.tgz",
"integrity": "sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.4.tgz",
"integrity": "sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz",
"integrity": "sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
...@@ -3457,22 +2778,6 @@ ...@@ -3457,22 +2778,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
...@@ -3755,6 +3060,7 @@ ...@@ -3755,6 +3060,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.19.tgz",
"integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==", "integrity": "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.19" "@tanstack/query-core": "5.90.19"
}, },
...@@ -4186,6 +3492,7 @@ ...@@ -4186,6 +3492,7 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
...@@ -4214,6 +3521,7 @@ ...@@ -4214,6 +3521,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
...@@ -4225,6 +3533,7 @@ ...@@ -4225,6 +3533,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
...@@ -4351,6 +3660,7 @@ ...@@ -4351,6 +3660,7 @@
"version": "2.9.15", "version": "2.9.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz",
"integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
...@@ -4386,6 +3696,7 @@ ...@@ -4386,6 +3696,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
...@@ -4420,6 +3731,7 @@ ...@@ -4420,6 +3731,7 @@
"version": "1.0.30001765", "version": "1.0.30001765",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
"integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==", "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
...@@ -4542,13 +3854,6 @@ ...@@ -4542,13 +3854,6 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT",
"peer": true
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
...@@ -4680,13 +3985,15 @@ ...@@ -4680,13 +3985,15 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.33.1", "version": "3.33.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
...@@ -5124,6 +4431,7 @@ ...@@ -5124,6 +4431,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
...@@ -5444,12 +4752,6 @@ ...@@ -5444,12 +4752,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-shallow-equal": { "node_modules/fast-shallow-equal": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
...@@ -5913,6 +5215,7 @@ ...@@ -5913,6 +5215,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
...@@ -6183,7 +5486,8 @@ ...@@ -6183,7 +5486,8 @@
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause",
"peer": true
}, },
"node_modules/leaflet.markercluster": { "node_modules/leaflet.markercluster": {
"version": "1.5.3", "version": "1.5.3",
...@@ -7548,89 +6852,6 @@ ...@@ -7548,89 +6852,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/next": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.4.tgz",
"integrity": "sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "16.1.4",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.4",
"@next/swc-darwin-x64": "16.1.4",
"@next/swc-linux-arm64-gnu": "16.1.4",
"@next/swc-linux-arm64-musl": "16.1.4",
"@next/swc-linux-x64-gnu": "16.1.4",
"@next/swc-linux-x64-musl": "16.1.4",
"@next/swc-win32-arm64-msvc": "16.1.4",
"@next/swc-win32-x64-msvc": "16.1.4",
"sharp": "^0.34.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
...@@ -7758,6 +6979,7 @@ ...@@ -7758,6 +6979,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
...@@ -7856,6 +7078,7 @@ ...@@ -7856,6 +7078,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
...@@ -7868,6 +7091,7 @@ ...@@ -7868,6 +7091,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
...@@ -7962,6 +7186,7 @@ ...@@ -7962,6 +7186,7 @@
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1", "license": "Hippocratic-2.1",
"peer": true,
"dependencies": { "dependencies": {
"@react-leaflet/core": "^2.1.0" "@react-leaflet/core": "^2.1.0"
}, },
...@@ -8459,12 +7684,6 @@ ...@@ -8459,12 +7684,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
...@@ -8480,66 +7699,6 @@ ...@@ -8480,66 +7699,6 @@
"node": ">=6.9" "node": ">=6.9"
} }
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
...@@ -8634,16 +7793,6 @@ ...@@ -8634,16 +7793,6 @@
"stacktrace-gps": "^3.0.4" "stacktrace-gps": "^3.0.4"
} }
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": { "node_modules/std-env": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
...@@ -8682,30 +7831,6 @@ ...@@ -8682,30 +7831,6 @@
"inline-style-parser": "0.2.7" "inline-style-parser": "0.2.7"
} }
}, },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/stylis": { "node_modules/stylis": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
...@@ -8883,7 +8008,8 @@ ...@@ -8883,7 +8008,8 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tw-animate-css": { "node_modules/tw-animate-css": {
"version": "1.4.0", "version": "1.4.0",
...@@ -8901,6 +8027,7 @@ ...@@ -8901,6 +8027,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
...@@ -9180,6 +8307,7 @@ ...@@ -9180,6 +8307,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
"format": "biome format --write src" "format": "biome format --write src"
}, },
"dependencies": { "dependencies": {
"@clerk/clerk-react": "^6.36.8", "@clerk/clerk-react": "^5.59.4",
"@connectrpc/connect": "^2.1.1", "@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1", "@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
...@@ -97,4 +97,4 @@ ...@@ -97,4 +97,4 @@
"esbuild" "esbuild"
] ]
} }
} }
\ No newline at end of file
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon } from "lucide-react"; import { ArchiveIcon, BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon } from "lucide-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -15,6 +15,7 @@ interface NavLinkItem { ...@@ -15,6 +15,7 @@ interface NavLinkItem {
path: string; path: string;
title: string; title: string;
icon: React.ReactNode; icon: React.ReactNode;
requiresAuth?: boolean;
} }
interface Props { interface Props {
...@@ -45,6 +46,7 @@ const Navigation = (props: Props) => { ...@@ -45,6 +46,7 @@ const Navigation = (props: Props) => {
path: Routes.ATTACHMENTS, path: Routes.ATTACHMENTS,
title: t("common.attachments"), title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />, icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
}; };
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length; const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const inboxNavLink: NavLinkItem = { const inboxNavLink: NavLinkItem = {
...@@ -61,10 +63,30 @@ const Navigation = (props: Props) => { ...@@ -61,10 +63,30 @@ const Navigation = (props: Props) => {
)} )}
</div> </div>
), ),
requiresAuth: true,
}; };
const navLinks: NavLinkItem[] = currentUser const archivedNavLink: NavLinkItem = {
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink] id: "header-archived",
: [exploreNavLink]; path: Routes.ARCHIVED,
title: t("common.archived"),
icon: <ArchiveIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
};
const settingsNavLink: NavLinkItem = {
id: "header-setting",
path: Routes.SETTING,
title: t("common.settings"),
icon: <SettingsIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
};
const navLinks: NavLinkItem[] = [
homeNavLink,
exploreNavLink,
attachmentsNavLink,
inboxNavLink,
archivedNavLink,
settingsNavLink,
];
return ( return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}> <header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}>
...@@ -78,13 +100,14 @@ const Navigation = (props: Props) => { ...@@ -78,13 +100,14 @@ const Navigation = (props: Props) => {
cn( cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors", "px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors",
collapsed ? "" : "w-full px-4", collapsed ? "" : "w-full px-4",
navLink.requiresAuth && !currentUser ? "opacity-60" : "",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow" ? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow"
: "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80", : "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80",
) )
} }
key={navLink.id} key={navLink.id}
to={navLink.path} to={navLink.requiresAuth && !currentUser ? Routes.AUTH : navLink.path}
id={navLink.id} id={navLink.id}
viewTransition viewTransition
> >
...@@ -106,11 +129,39 @@ const Navigation = (props: Props) => { ...@@ -106,11 +129,39 @@ const Navigation = (props: Props) => {
</NavLink> </NavLink>
))} ))}
</div> </div>
{currentUser && ( <div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}> {currentUser ? (
<UserMenu collapsed={collapsed} /> <UserMenu collapsed={collapsed} />
</div> ) : (
)} <NavLink
to={Routes.AUTH}
className={cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80",
collapsed ? "" : "w-full px-4",
)}
>
{collapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<User2Icon className="w-6 h-auto shrink-0" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t("common.sign-in")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<User2Icon className="w-6 h-auto shrink-0" />
<span className="ml-3 truncate">{t("common.sign-in")}</span>
</>
)}
</NavLink>
)}
</div>
</header> </header>
); );
}; };
......
import { MoreVerticalIcon, PenLineIcon } from "lucide-react"; // import { UserProfile, SignedIn, SignedOut, SignInButton } from "@clerk/clerk-react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import UpdateAccountDialog from "../UpdateAccountDialog";
import UserAvatar from "../UserAvatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import AccessTokenSection from "./AccessTokenSection";
import SettingGroup from "./SettingGroup"; import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import AccessTokenSection from "./AccessTokenSection";
import LocaleSelect from "../LocaleSelect";
import ThemeSelect from "../ThemeSelect";
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { useAuth } from "@/contexts/AuthContext";
import { create } from "@bufbuild/protobuf";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { loadLocale } from "@/utils/i18n";
import { loadTheme } from "@/utils/theme";
import SettingRow from "./SettingRow";
const MyAccountSection = () => { const MyAccountSection = () => {
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser();
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => { const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
accountDialog.open(); const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
loadLocale(locale);
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{ onSuccess: () => refetchSettings() },
);
}; };
const handleChangePassword = () => { const handleThemeChange = async (theme: string) => {
passwordDialog.open(); loadTheme(theme);
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{ onSuccess: () => refetchSettings() },
);
}; };
const setting: UserSetting_GeneralSetting =
generalSetting ||
create(UserSetting_GeneralSettingSchema, {
locale: "en",
memoVisibility: "PRIVATE",
theme: "system",
});
return ( return (
<SettingSection> <SettingSection>
<SettingGroup title={t("setting.account-section.title")}> <SettingGroup title="Clerk Profile">
<div className="w-full flex flex-row justify-start items-center gap-3"> <div className="w-full flex justify-center py-4 border border-dashed border-border rounded-lg bg-muted/30">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} /> {/* Placeholder for Clerk UserProfile */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1"> <div className="text-center p-6">
<div className="w-full"> <p className="text-muted-foreground font-medium">Authentication is managed by Clerk</p>
<span className="text-lg font-semibold">{user?.displayName}</span> <p className="text-xs text-muted-foreground mt-1">(UserProfile component will appear here when Auth is enabled)</p>
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
</div>
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleEditAccount}>
<PenLineIcon className="w-4 h-4 mr-1.5" />
{t("common.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVerticalIcon className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account-section.change-password")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
{/* <SignedIn>
<UserProfile
routing="virtual"
appearance={{
elements: {
rootBox: "w-full shadow-none",
card: "w-full shadow-none border-0 bg-transparent",
navbar: "hidden",
navbarMobileMenuButton: "hidden",
headerTitle: "hidden",
headerSubtitle: "hidden",
}
}}
/>
</SignedIn>
<SignedOut>
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-muted-foreground">Please sign in to manage your account.</p>
<SignInButton mode="modal">
<Button>Sign In</Button>
</SignInButton>
</div>
</SignedOut> */}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup showSeparator> <SettingGroup title={t("setting.preference")} showSeparator>
<AccessTokenSection /> <SettingRow label={t("common.language")}>
</SettingGroup> <LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
</SettingRow>
{/* Update Account Dialog */} <SettingRow label={t("setting.preference-section.theme")}>
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} /> <ThemeSelect value={setting.theme} onValueChange={handleThemeChange} />
</SettingRow>
</SettingGroup>
{/* Change Password Dialog */} <SettingGroup title={t("setting.access-token")} showSeparator>
<ChangeMemberPasswordDialog open={passwordDialog.isOpen} onOpenChange={passwordDialog.setOpen} user={user} /> <AccessTokenSection />
</SettingGroup>
</SettingSection> </SettingSection>
); );
}; };
......
import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react"; import { ArchiveIcon, LogOutIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { locales } from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { getLocaleDisplayName, getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "./ui/dropdown-menu"; } from "./ui/dropdown-menu";
...@@ -28,40 +22,7 @@ const UserMenu = (props: Props) => { ...@@ -28,40 +22,7 @@ const UserMenu = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { userGeneralSetting, refetchSettings, logout } = useAuth(); const { logout } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
// Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleThemeChange = async (theme: string) => {
if (!currentUser) return;
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleSignOut = async () => { const handleSignOut = async () => {
// First, clear auth state and cache BEFORE doing anything else // First, clear auth state and cache BEFORE doing anything else
...@@ -114,36 +75,6 @@ const UserMenu = (props: Props) => { ...@@ -114,36 +75,6 @@ const UserMenu = (props: Props) => {
<ArchiveIcon className="size-4 text-muted-foreground" /> <ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")} {t("common.archived")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />
{t("common.language")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto">
{locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}>
{currentLocale === locale && <CheckIcon className="w-4 h-auto" />}
{currentLocale !== locale && <span className="w-4" />}
{getLocaleDisplayName(locale)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon className="size-4 text-muted-foreground" />
{t("setting.preference-section.theme")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => handleThemeChange(option.value)}>
{currentTheme === option.value && <CheckIcon className="w-4 h-auto" />}
{currentTheme !== option.value && <span className="w-4" />}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}> <DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}>
<SettingsIcon className="size-4 text-muted-foreground" /> <SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")} {t("common.settings")}
......
...@@ -39,7 +39,10 @@ type ApiMemo = { ...@@ -39,7 +39,10 @@ type ApiMemo = {
content: string; content: string;
visibility?: string | null; visibility?: string | null;
tags?: string[]; tags?: string[];
creator_id?: number; creator_id?: number | string;
create_time?: string;
update_time?: string;
display_time?: string;
}; };
type ApiAttachment = { type ApiAttachment = {
...@@ -194,9 +197,20 @@ const visibilityToApi = (value?: Visibility): string => { ...@@ -194,9 +197,20 @@ const visibilityToApi = (value?: Visibility): string => {
} }
}; };
const parseTimestamp = (dateStr?: string) => {
if (!dateStr) return undefined;
// Handle "YYYY-MM-DD HH:MM:SS" SQLite string format
// Append 'Z' to treat as UTC if no timezone info, or assume local?
// Backend stores DATETIME('now') which is UTC.
const date = new Date(dateStr.endsWith("Z") ? dateStr : `${dateStr}Z`);
const seconds = BigInt(Math.floor(date.getTime() / 1000));
return { seconds, nanos: 0 } as any;
};
const memoFromApi = (raw: ApiMemo): Memo => { const memoFromApi = (raw: ApiMemo): Memo => {
const content = raw.content || ""; const content = raw.content || "";
return { console.log("DEBUG: memoFromApi input:", raw);
const result = {
name: `memos/${raw.id}`, name: `memos/${raw.id}`,
state: State.NORMAL, state: State.NORMAL,
creator: `users/${raw.creator_id ?? 1}`, creator: `users/${raw.creator_id ?? 1}`,
...@@ -214,7 +228,12 @@ const memoFromApi = (raw: ApiMemo): Memo => { ...@@ -214,7 +228,12 @@ const memoFromApi = (raw: ApiMemo): Memo => {
hasIncompleteTasks: false, hasIncompleteTasks: false,
}, },
snippet: buildSnippet(content), snippet: buildSnippet(content),
createTime: parseTimestamp(raw.create_time),
updateTime: parseTimestamp(raw.update_time),
displayTime: parseTimestamp(raw.display_time),
}; };
console.log("DEBUG: memoFromApi output:", result);
return result;
}; };
const userFromApi = (raw: ApiUser): User => { const userFromApi = (raw: ApiUser): User => {
......
import { useAuth } from "@/contexts/AuthContext"; import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
const useCurrentUser = () => { const useCurrentUser = () => {
const { currentUser } = useAuth(); // Mock user for testing without backend/auth
return currentUser; return {
name: "users/test-user",
id: 1,
role: User_Role.HOST,
email: "test@example.com",
nickname: "Test User",
avatarUrl: "",
description: "",
createTime: undefined,
updateTime: undefined,
rowStatus: "NORMAL"
} as unknown as User;
// Real implementation:
// const { currentUser } = useAuth();
// return currentUser;
}; };
export default useCurrentUser; export default useCurrentUser;
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect, useMemo } from "react";
import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom"; import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious"; import usePrevious from "react-use/lib/usePrevious";
import { SignedIn, SignedOut, UserButton } from "@clerk/clerk-react"; // import { SignedIn, SignedOut, UserButton } from "@clerk/clerk-react";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import ChatbotWidget from "@/components/ChatbotWidget"; import ChatbotWidget from "@/components/ChatbotWidget";
import Spinner from "@/components/Spinner"; import Spinner from "@/components/Spinner";
...@@ -22,11 +22,11 @@ const RootLayout = () => { ...@@ -22,11 +22,11 @@ const RootLayout = () => {
const pathname = useMemo(() => location.pathname, [location.pathname]); const pathname = useMemo(() => location.pathname, [location.pathname]);
const prevPathname = usePrevious(pathname); const prevPathname = usePrevious(pathname);
useEffect(() => { // useEffect(() => {
if (!currentUser && memoRelatedSetting.disallowPublicVisibility) { // if (!currentUser && memoRelatedSetting.disallowPublicVisibility) {
redirectOnAuthFailure(); // redirectOnAuthFailure();
} // }
}, [currentUser, memoRelatedSetting.disallowPublicVisibility]); // }, [currentUser, memoRelatedSetting.disallowPublicVisibility]);
useEffect(() => { useEffect(() => {
// When the route changes and there is no filter in the search params, remove all filters // When the route changes and there is no filter in the search params, remove all filters
...@@ -59,7 +59,7 @@ const RootLayout = () => { ...@@ -59,7 +59,7 @@ const RootLayout = () => {
<Outlet /> <Outlet />
</Suspense> </Suspense>
</main> </main>
<div className="fixed top-4 right-4 z-50"> {/* <div className="fixed top-4 right-4 z-50">
<SignedOut> <SignedOut>
<Link <Link
to="/auth" to="/auth"
...@@ -71,7 +71,7 @@ const RootLayout = () => { ...@@ -71,7 +71,7 @@ const RootLayout = () => {
<SignedIn> <SignedIn>
<UserButton /> <UserButton />
</SignedIn> </SignedIn>
</div> </div> */}
<ChatbotWidget /> <ChatbotWidget />
</div> </div>
); );
......
import "@github/relative-time-element"; import "@github/relative-time-element";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ClerkProvider } from "@clerk/clerk-react"; // import { ClerkProvider } from "@clerk/clerk-react";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
...@@ -52,6 +52,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) { ...@@ -52,6 +52,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
} }
function Main() { function Main() {
/*
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
if (!publishableKey) { if (!publishableKey) {
return ( return (
...@@ -73,27 +74,33 @@ VITE_API_BASE_URL=http://localhost:5000 ...@@ -73,27 +74,33 @@ VITE_API_BASE_URL=http://localhost:5000
</div> </div>
); );
} }
*/
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ClerkProvider publishableKey={publishableKey}> {/* <ClerkProvider
<QueryClientProvider client={queryClient}> publishableKey={publishableKey}
<InstanceProvider> signInUrl="/auth"
<AuthProvider> signUpUrl="/auth?mode=signup"
<ViewProvider> fallbackRedirectUrl="/app"
<AppInitializer> > */}
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
<Toaster position="top-right" /> <InstanceProvider>
</AppInitializer> <AuthProvider>
</ViewProvider> <ViewProvider>
</AuthProvider> <AppInitializer>
</InstanceProvider> <RouterProvider router={router} />
</QueryClientProvider> <Toaster position="top-right" />
</ClerkProvider> </AppInitializer>
</ViewProvider>
</AuthProvider>
</InstanceProvider>
</QueryClientProvider>
{/* </ClerkProvider> */}
</ErrorBoundary> </ErrorBoundary>
); );
} }
const container = document.getElementById("root"); const container = document.getElementById("root");
const root = createRoot(container as HTMLElement); const root = createRoot(container as HTMLElement);
root.render(<Main />); root.render(<Main />);
\ No newline at end of file
import { ArchiveIcon } from "lucide-react";
import { MemoRenderContext } from "@/components/MasonryView"; import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList"; import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks"; import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Archived = () => { const Archived = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const user = useCurrentUser(); const user = useCurrentUser();
// Build filter using unified hook (no shortcuts or pinned filter) // Build filter using unified hook (no shortcuts or pinned filter)
...@@ -23,15 +29,30 @@ const Archived = () => { ...@@ -23,15 +29,30 @@ const Archived = () => {
}); });
return ( return (
<PagedMemoList <section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
renderer={(memo: Memo, context?: MemoRenderContext) => ( {!md && <MobileHeader />}
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} /> <div className="w-full px-4 sm:px-6">
)} <div className="w-full border border-border flex flex-col justify-start items-start rounded-xl bg-background text-foreground overflow-hidden">
listSort={listSort} <div className="w-full px-4 py-4 border-b border-border">
state={State.ARCHIVED} <div className="flex flex-row items-center gap-2">
orderBy={orderBy} <ArchiveIcon className="w-5 h-auto text-muted-foreground" />
filter={memoFilter} <h1 className="text-xl font-semibold">{t("common.archived")}</h1>
/> </div>
</div>
<div className="w-full px-4 py-8">
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
)}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
filter={memoFilter}
/>
</div>
</div>
</div>
</section>
); );
}; };
......
/* ============================================
AUTH PAGE STYLES
============================================ */
.auth-page {
min-height: 100vh;
width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
position: relative;
overflow: hidden;
}
/* Background Orbs */
.auth-background {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.auth-gradient-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.5;
animation: authFloat 25s ease-in-out infinite;
}
.auth-gradient-orb.orb-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%);
top: -150px;
right: -100px;
animation-delay: 0s;
}
.auth-gradient-orb.orb-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.35) 0%, transparent 70%);
bottom: -100px;
left: -100px;
animation-delay: -8s;
}
.auth-gradient-orb.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.25) 0%, transparent 70%);
top: 40%;
left: 30%;
animation-delay: -16s;
}
@keyframes authFloat {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(40px, -40px) scale(1.1);
}
66% {
transform: translate(-30px, 30px) scale(0.9);
}
}
/* Container */
.auth-container {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 80px;
max-width: 1100px;
width: 100%;
}
/* Branding Section */
.auth-branding {
flex: 1;
max-width: 450px;
}
.auth-logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 40px;
cursor: pointer;
transition: opacity 0.2s;
}
.auth-logo:hover {
opacity: 0.8;
}
.auth-logo svg {
width: 48px;
height: 48px;
}
.auth-title {
font-size: clamp(32px, 5vw, 48px);
font-weight: 800;
color: #fff;
line-height: 1.2;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
.auth-subtitle {
font-size: 17px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.7;
margin-bottom: 40px;
}
.auth-features {
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-feature {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
color: rgba(255, 255, 255, 0.8);
}
.auth-feature svg {
color: #10b981;
flex-shrink: 0;
}
/* Form Container */
.auth-form-container {
flex: 1;
max-width: 420px;
display: flex;
justify-content: center;
}
/* Clerk Customizations */
.auth-form-container :global(.cl-rootBox) {
width: 100%;
}
.auth-form-container :global(.cl-card) {
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 20px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4) !important;
backdrop-filter: blur(20px);
}
.auth-form-container :global(.cl-headerTitle) {
color: #fff !important;
font-weight: 700 !important;
}
.auth-form-container :global(.cl-headerSubtitle) {
color: rgba(255, 255, 255, 0.6) !important;
}
.auth-form-container :global(.cl-socialButtonsBlockButton) {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 12px !important;
color: #fff !important;
transition: all 0.2s ease !important;
}
.auth-form-container :global(.cl-socialButtonsBlockButton:hover) {
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
}
.auth-form-container :global(.cl-formFieldInput) {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 12px !important;
color: #fff !important;
}
.auth-form-container :global(.cl-formFieldInput:focus) {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
}
.auth-form-container :global(.cl-formButtonPrimary) {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border-radius: 12px !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
}
.auth-form-container :global(.cl-formButtonPrimary:hover) {
transform: translateY(-2px) !important;
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4) !important;
}
.auth-form-container :global(.cl-footerActionLink) {
color: #a5b4fc !important;
}
.auth-form-container :global(.cl-footerActionLink:hover) {
color: #c7d2fe !important;
}
.auth-form-container :global(.cl-dividerLine) {
background: rgba(255, 255, 255, 0.1) !important;
}
.auth-form-container :global(.cl-dividerText) {
color: rgba(255, 255, 255, 0.4) !important;
}
.auth-form-container :global(.cl-formFieldLabel) {
color: rgba(255, 255, 255, 0.8) !important;
}
.auth-form-container :global(.cl-internal-b3fm6y) {
color: rgba(255, 255, 255, 0.5) !important;
}
/* Signed In State */
.auth-signed-in {
text-align: center;
padding: 48px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
backdrop-filter: blur(20px);
}
.auth-signed-in-icon {
margin-bottom: 24px;
}
.auth-signed-in h2 {
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 8px;
}
.auth-signed-in p {
font-size: 15px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 32px;
}
.auth-go-to-app {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.auth-go-to-app:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
}
/* Back Button */
.auth-back-btn {
position: absolute;
top: 24px;
left: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
}
.auth-back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Responsive */
@media (max-width: 900px) {
.auth-container {
flex-direction: column;
gap: 48px;
}
.auth-branding {
text-align: center;
max-width: 100%;
}
.auth-features {
align-items: center;
}
.auth-form-container {
width: 100%;
max-width: 400px;
}
}
@media (max-width: 480px) {
.auth-page {
padding: 80px 16px 40px;
}
.auth-title {
font-size: 28px;
}
.auth-features {
display: none;
}
}
\ No newline at end of file
import { SignedIn, SignedOut, SignIn } from "@clerk/clerk-react"; import { SignedIn, SignedOut, SignIn, SignUp, useAuth } from "@clerk/clerk-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import "./Auth.css";
const AuthPage = () => { const AuthPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isSignedIn } = useAuth();
const [searchParams] = useSearchParams();
const mode = searchParams.get("mode"); // "signup" or null (signin)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const next = params.get("redirect") || "/app";
if (!isSignedIn) return;
if (window.location.pathname.startsWith("/auth")) {
navigate(next);
}
}, [isSignedIn, navigate]);
return (
<div className="auth-page">
{/* Background */}
<div className="auth-background">
<div className="auth-gradient-orb orb-1"></div>
<div className="auth-gradient-orb orb-2"></div>
<div className="auth-gradient-orb orb-3"></div>
</div>
// Nếu đã đăng nhập rồi thì đưa về trang chủ {/* Content */}
useEffect(() => { <div className="auth-container">
// nhỏ gọn: SignedIn phía dưới cũng handle, đây chỉ là fallback {/* Left Side - Branding */}
}, [navigate]); <div className="auth-branding">
<div className="auth-logo" onClick={() => navigate("/")}>
<svg width="48" height="48" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#authGradient)" />
<path d="M10 16L14 20L22 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="authGradient" x1="0" y1="0" x2="32" y2="32">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>BasicNotion</span>
</div>
<h1 className="auth-title">
{mode === "signup" ? "Start your journey" : "Welcome back"}
</h1>
<p className="auth-subtitle">
{mode === "signup"
? "Create your account and start organizing your thoughts today."
: "Sign in to continue to your notes and ideas."}
</p>
<div className="auth-features">
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Quick capture thoughts</span>
</div>
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Sync across all devices</span>
</div>
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>AI-powered assistant</span>
</div>
</div>
</div>
return ( {/* Right Side - Auth Form */}
<div className="w-full min-h-svh flex items-center justify-center bg-background px-4"> <div className="auth-form-container">
<div className="max-w-md w-full flex flex-col items-center gap-6"> <SignedOut>
<SignedOut> {mode === "signup" ? (
<SignIn <SignUp
routing="path" appearance={{
path="/auth" elements: {
signUpUrl="/auth" rootBox: "clerk-root-box",
redirectUrl="/" card: "clerk-card",
/> headerTitle: "clerk-header-title",
</SignedOut> headerSubtitle: "clerk-header-subtitle",
<SignedIn> socialButtonsBlockButton: "clerk-social-btn",
<button formFieldInput: "clerk-input",
type="button" formButtonPrimary: "clerk-primary-btn",
className="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90" footerActionLink: "clerk-footer-link",
onClick={() => navigate("/")} },
> variables: {
You are already signed in – Go to app colorPrimary: "#6366f1",
</button> colorBackground: "#1a1a2e",
</SignedIn> colorText: "#ffffff",
colorTextSecondary: "rgba(255,255,255,0.6)",
colorInputBackground: "rgba(255,255,255,0.05)",
colorInputText: "#ffffff",
borderRadius: "12px",
},
}}
routing="path"
path="/auth"
signInUrl="/auth"
fallbackRedirectUrl="/app"
/>
) : (
<SignIn
appearance={{
elements: {
rootBox: "clerk-root-box",
card: "clerk-card",
headerTitle: "clerk-header-title",
headerSubtitle: "clerk-header-subtitle",
socialButtonsBlockButton: "clerk-social-btn",
formFieldInput: "clerk-input",
formButtonPrimary: "clerk-primary-btn",
footerActionLink: "clerk-footer-link",
},
variables: {
colorPrimary: "#6366f1",
colorBackground: "#1a1a2e",
colorText: "#ffffff",
colorTextSecondary: "rgba(255,255,255,0.6)",
colorInputBackground: "rgba(255,255,255,0.05)",
colorInputText: "#ffffff",
borderRadius: "12px",
},
}}
routing="path"
path="/auth"
signUpUrl="/auth?mode=signup"
fallbackRedirectUrl="/app"
/>
)}
</SignedOut>
<SignedIn>
<div className="auth-signed-in">
<div className="auth-signed-in-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2>You're signed in!</h2>
<p>You're already logged in to your account.</p>
<button
type="button"
className="auth-go-to-app"
onClick={() => navigate("/app")}
>
Go to Dashboard
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.167 10h11.666M10 4.167L15.833 10 10 15.833" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</SignedIn>
</div>
</div> </div>
{/* Back to Landing */}
<button className="auth-back-btn" onClick={() => navigate("/")}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.833 10H4.167M10 15.833L4.167 10 10 4.167" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Back to home
</button>
</div> </div>
); );
}; };
export default AuthPage; export default AuthPage;
import { AuthenticateWithRedirectCallback } from "@clerk/clerk-react";
const AuthSsoCallback = () => {
return <AuthenticateWithRedirectCallback redirectUrl="/app" />;
};
export default AuthSsoCallback;
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react"; import { BellIcon, InboxIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import Empty from "@/components/Empty"; import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage"; import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
...@@ -14,7 +14,7 @@ import { useTranslate } from "@/utils/i18n"; ...@@ -14,7 +14,7 @@ import { useTranslate } from "@/utils/i18n";
const Inboxes = () => { const Inboxes = () => {
const t = useTranslate(); const t = useTranslate();
const md = useMediaQuery("md"); const md = useMediaQuery("md");
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all"); const [filter, setFilter] = useState<"all" | "unread">("all");
// Fetch notifications with React Query // Fetch notifications with React Query
const { data: fetchedNotifications = [] } = useNotifications(); const { data: fetchedNotifications = [] } = useNotifications();
...@@ -25,12 +25,11 @@ const Inboxes = () => { ...@@ -25,12 +25,11 @@ const Inboxes = () => {
const notifications = allNotifications.filter((notification) => { const notifications = allNotifications.filter((notification) => {
if (filter === "unread") return notification.status === UserNotification_Status.UNREAD; if (filter === "unread") return notification.status === UserNotification_Status.UNREAD;
if (filter === "archived") return notification.status === UserNotification_Status.ARCHIVED; return notification.status !== UserNotification_Status.ARCHIVED;
return true;
}); });
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length; const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length; // const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length;
return ( return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8"> <section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
...@@ -78,18 +77,6 @@ const Inboxes = () => { ...@@ -78,18 +77,6 @@ const Inboxes = () => {
<InboxIcon className="w-3.5 h-auto" /> <InboxIcon className="w-3.5 h-auto" />
{t("inbox.unread")} ({unreadCount}) {t("inbox.unread")} ({unreadCount})
</button> </button>
<button
onClick={() => setFilter("archived")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === "archived"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<ArchiveIcon className="w-3.5 h-auto" />
{t("common.archived")} ({archivedCount})
</button>
</div> </div>
</div> </div>
...@@ -99,7 +86,7 @@ const Inboxes = () => { ...@@ -99,7 +86,7 @@ const Inboxes = () => {
<div className="w-full py-16 flex flex-col justify-center items-center"> <div className="w-full py-16 flex flex-col justify-center items-center">
<Empty /> <Empty />
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")} {filter === "unread" ? t("inbox.no-unread") : t("message.no-data")}
</p> </p>
</div> </div>
) : ( ) : (
......
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap");
:root {
--bg-primary: #0c0c0c;
--bg-secondary: #111111;
--glass: rgba(255, 255, 255, 0.06);
--glass-strong: rgba(255, 255, 255, 0.1);
--text-main: #f5f5f5;
--text-muted: #b3b3b3;
--border: rgba(255, 255, 255, 0.1);
--shadow-soft: 0 20px 60px rgba(0, 0, 0, 0.55);
--radius: 18px;
}
.landing-shell {
min-height: 100vh;
background: radial-gradient(120% 120% at 20% 20%, rgba(255, 255, 255, 0.05) 0%, transparent 40%),
radial-gradient(120% 120% at 80% 10%, rgba(255, 255, 255, 0.05) 0%, transparent 35%),
linear-gradient(135deg, var(--bg-primary), #0f0f0f 45%, #0b0b0b 100%);
color: var(--text-main);
font-family: "Space Grotesk", "Inter", system-ui, -apple-system, sans-serif;
position: relative;
overflow: hidden;
}
.landing-bg {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.gradient-blob {
position: absolute;
border-radius: 999px;
filter: blur(120px);
opacity: 0.35;
}
.orb-a {
width: 420px;
height: 420px;
background: rgba(255, 255, 255, 0.08);
top: -120px;
right: -80px;
}
.orb-b {
width: 320px;
height: 320px;
background: rgba(255, 255, 255, 0.05);
bottom: -120px;
left: 0;
}
.orb-c {
width: 480px;
height: 480px;
background: rgba(255, 255, 255, 0.04);
top: 40%;
left: 40%;
}
.landing-container {
max-width: 1180px;
margin: 0 auto;
padding: 0 24px;
position: relative;
z-index: 1;
}
.landing-topbar {
position: sticky;
top: 0;
z-index: 20;
background: linear-gradient(180deg, rgba(10, 15, 27, 0.7) 0%, rgba(10, 15, 27, 0.3) 70%, transparent 100%);
backdrop-filter: blur(16px);
}
.topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
background: var(--glass);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
box-shadow: var(--shadow-soft);
}
.mark-glow {
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.12), transparent 55%);
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand-name {
font-weight: 700;
letter-spacing: -0.02em;
}
.brand-tagline {
color: var(--text-muted);
font-size: 13px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.landing-main {
padding: 32px 0 80px;
}
.hero-section {
padding: 40px 0 24px;
}
.hero-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
align-items: center;
}
.hero-copy {
grid-column: span 7;
display: flex;
flex-direction: column;
gap: 18px;
}
.hero-pill {
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.35);
}
.hero-title {
font-size: clamp(34px, 4vw, 56px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.05;
}
.title-accent {
color: #e6e6e6;
text-shadow: 0 0 24px rgba(255, 255, 255, 0.12);
}
.hero-subtitle {
color: var(--text-muted);
font-size: 16px;
line-height: 1.7;
max-width: 640px;
}
.hero-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.cta-strong {
background: linear-gradient(120deg, #f5f5f5, #dcdcdc);
color: #0c0c0c;
border: none;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
}
.cta-strong:hover {
filter: brightness(0.97);
transform: translateY(-1px);
}
.cta-ghost {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
}
.cta-ghost:hover {
background: rgba(255, 255, 255, 0.1);
}
.hero-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 10px;
}
.stat-chip {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
}
.stat-value {
font-size: 18px;
font-weight: 700;
}
.stat-label {
color: var(--text-muted);
font-size: 13px;
}
.hero-showcase {
grid-column: span 5;
display: flex;
flex-direction: column;
gap: 14px;
}
.glass-panel {
border-radius: 22px;
padding: 18px 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(10px);
}
.glass-panel.secondary {
border-style: dashed;
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.05);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.panel-pill {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border: 1px solid rgba(255, 255, 255, 0.16);
}
.panel-badge {
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-main);
}
.panel-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.panel-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.panel-row.compact {
grid-template-columns: 1fr;
padding: 6px 0 0;
border-bottom: none;
}
.panel-dot {
width: 12px;
height: 12px;
border-radius: 999px;
background: #60a5fa;
box-shadow: 0 0 0 6px rgba(96, 165, 250, 0.15);
}
.panel-dot.hot {
background: #f472b6;
box-shadow: 0 0 0 6px rgba(244, 114, 182, 0.14);
}
.panel-title {
font-weight: 600;
}
.panel-desc {
color: var(--text-muted);
font-size: 13px;
}
.panel-tag {
font-size: 12px;
color: var(--text-muted);
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.panel-progress {
height: 8px;
width: 100%;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
overflow: hidden;
}
.panel-progress span {
display: block;
height: 100%;
background: linear-gradient(90deg, #f5f5f5, #bfbfbf);
border-radius: 999px;
}
.panel-footnote {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
margin-top: 6px;
}
.panel-stack {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.stack-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border: 1px solid rgba(255, 255, 255, 0.16);
}
.stack-chip.alt {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border-color: rgba(255, 255, 255, 0.14);
}
.panel-subcopy {
color: var(--text-muted);
font-size: 14px;
line-height: 1.6;
}
.feature-section {
padding: 26px 0;
}
.section-heading {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 720px;
}
.section-heading h2 {
font-size: clamp(26px, 3vw, 38px);
letter-spacing: -0.02em;
}
.heading-pill {
width: fit-content;
border-color: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.04);
color: var(--text-main);
}
.section-subtitle {
color: var(--text-muted);
font-size: 15px;
line-height: 1.6;
}
.feature-grid {
margin-top: 24px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.feature-card {
position: relative;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
overflow: hidden;
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
align-items: center;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.feature-card:hover {
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 16px 45px rgba(0, 0, 0, 0.35);
}
.feature-card::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.35;
background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.12), transparent 35%);
}
.feature-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: grid;
place-items: center;
color: #0b0b0b;
font-weight: 600;
z-index: 1;
}
.feature-content {
z-index: 1;
}
.feature-content h3 {
font-weight: 700;
margin-bottom: 4px;
}
.feature-content p {
color: var(--text-muted);
font-size: 14px;
line-height: 1.6;
}
.feature-tag {
position: absolute;
bottom: 12px;
right: 14px;
padding: 6px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.tone-violet .feature-icon,
.tone-rose .feature-icon,
.tone-emerald .feature-icon,
.tone-sky .feature-icon,
.tone-amber .feature-icon,
.tone-blue .feature-icon {
background: linear-gradient(135deg, #f5f5f5, #dcdcdc);
}
.steps-section {
padding: 36px 0 48px;
}
.steps-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
align-items: start;
}
.steps-actions {
display: flex;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.steps-cards {
grid-column: span 6 / span 6;
display: grid;
gap: 12px;
}
.section-heading {
grid-column: span 6 / span 6;
}
.step-card {
display: grid;
grid-template-columns: auto 1fr;
gap: 12px;
padding: 14px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.03);
}
.step-number {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
background: rgba(99, 102, 241, 0.2);
color: #c7d2fe;
font-weight: 700;
}
.step-title {
font-weight: 600;
margin-bottom: 4px;
}
.step-desc {
color: var(--text-muted);
font-size: 14px;
line-height: 1.6;
}
.landing-footer {
border-top: 1px solid var(--border);
padding: 28px 0 36px;
margin-top: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent);
}
.footer-inner {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
flex-wrap: wrap;
}
.footer-subtitle {
color: var(--text-muted);
margin-top: 6px;
max-width: 540px;
}
.footer-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 1024px) {
.hero-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.hero-copy {
grid-column: span 1;
}
.hero-showcase {
grid-column: span 1;
}
.steps-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.section-heading,
.steps-cards {
grid-column: span 1;
}
}
@media (max-width: 640px) {
.topbar-inner {
gap: 10px;
}
.hero-meta {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.feature-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.glass-panel,
.feature-card {
padding: 16px;
}
.landing-main {
padding-top: 14px;
}
}
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
// import { SignedIn, SignedOut } from "@clerk/clerk-react";
import { Clock3, LineChart, PanelsTopLeft, PenSquare, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { useNavigate } from "react-router-dom";
import "./Landing.css";
const stats = [
{ label: "Teams ship faster", value: "12K+" },
{ label: "Memos crafted", value: "480K" },
{ label: "Sync uptime", value: "99.9%" },
];
const features = [
{
title: "Realtime canvas",
description: "Compose, link, and reorganize ideas with multiplayer cursors and keyboard-first controls.",
tone: "violet",
icon: PanelsTopLeft,
},
{
title: "AI co-editor",
description: "Summaries, briefs, and rewrite suggestions powered by an on-brand prompt library.",
tone: "rose",
icon: Sparkles,
},
{
title: "Encrypted workspace",
description: "Zero-knowledge sync, role-based access, and audit trails baked in by default.",
tone: "emerald",
icon: ShieldCheck,
},
{
title: "Instant capture",
description: "Drop thoughts from inbox, voice, or web clipper — we auto-tag and group for you.",
tone: "sky",
icon: PenSquare,
},
{
title: "Insightful timelines",
description: "Spot trends across projects with activity heatmaps, streaks, and velocity snapshots.",
tone: "amber",
icon: LineChart,
},
{
title: "Lightning performance",
description: "Start typing in under 400ms, offline-first caching, and diff-friendly exports.",
tone: "blue",
icon: Zap,
},
];
const steps = [
{
title: "Capture beautifully",
description: "Write or drop anything — quick actions, slash commands, and AI clean-up keep flow uninterrupted.",
},
{
title: "Organize automatically",
description: "Smart tags, relations, and saved views keep every memo discoverable without manual foldering.",
},
{
title: "Publish with confidence",
description: "Share live docs, embed cards, or export PDFs. Permissions and history stay attached.",
},
];
const Landing = () => {
const navigate = useNavigate();
return (
<div className="landing-shell">
<div className="landing-bg">
<div className="gradient-blob orb-a" />
<div className="gradient-blob orb-b" />
<div className="gradient-blob orb-c" />
</div>
<header className="landing-topbar">
<div className="landing-container topbar-inner">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<span className="mark-glow" />
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#landingGradient)" />
<path d="M10 16L14 20L22 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="landingGradient" x1="0" y1="0" x2="32" y2="32">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
</div>
<div className="brand-copy">
<p className="brand-name">BasicNotion</p>
<p className="brand-tagline">Crafted memos, powered by AI</p>
</div>
</div>
<nav className="top-actions">
<Button size="sm" onClick={() => navigate("/app")}>
Test App
</Button>
</nav>
</div>
</header>
<main className="landing-main">
<section className="hero-section">
<div className="landing-container hero-grid">
<div className="hero-copy">
<Badge variant="secondary" className="hero-pill">
<Sparkles className="size-4" />
Giao diện shadcn mới
</Badge>
<h1 className="hero-title">
Ghi chú nhanh, <span className="title-accent">trình bày sang xịn</span> như một sản phẩm đã hoàn thiện.
</h1>
<p className="hero-subtitle">
Một landing shadcn gọn gàng, nền gradient, thẻ glassy, và CTA rõ ràng. Lướt mượt, dùng dễ, tập trung vào nội dung.
</p>
<div className="hero-actions">
<Button size="lg" className="cta-strong" onClick={() => navigate("/app")}>
Vào Dashboard (Test Mode) <ArrowRightIcon />
</Button>
</div>
<div className="hero-meta">
{stats.map((item) => (
<div className="stat-chip" key={item.label}>
<p className="stat-value">{item.value}</p>
<p className="stat-label">{item.label}</p>
</div>
))}
</div>
</div>
<div className="hero-showcase">
<div className="glass-panel">
<div className="panel-header">
<div className="panel-pill">
<ShieldCheck className="size-4" />
Private by default
</div>
<Badge variant="outline" className="panel-badge">
Live sync
</Badge>
</div>
<div className="panel-body">
<div className="panel-row">
<div className="panel-dot hot" />
<div>
<p className="panel-title">AI Briefing</p>
<p className="panel-desc">Summaries stay on-device with encrypted sync.</p>
</div>
<span className="panel-tag">Realtime</span>
</div>
<div className="panel-row">
<div className="panel-dot" />
<div>
<p className="panel-title">Ideas inbox</p>
<p className="panel-desc">Clip from mobile, web, or email — auto-tagged.</p>
</div>
<span className="panel-tag">Auto</span>
</div>
<div className="panel-row compact">
<div className="panel-progress">
<span style={{ width: "76%" }} />
</div>
<p className="panel-footnote">
<Clock3 className="size-4" />
Response SLA: 420ms
</p>
</div>
</div>
</div>
<div className="glass-panel secondary">
<div className="panel-stack">
<div className="stack-chip">
<Sparkles className="size-4" />
Smart suggestions
</div>
<div className="stack-chip alt">
<PenSquare className="size-4" />
Inline edits
</div>
<div className="stack-chip alt">
<LineChart className="size-4" />
Trends
</div>
</div>
<p className="panel-subcopy">Shadcn spacing, rounded cards, và màu sắc pastel để UI nhẹ mà vẫn sắc.</p>
</div>
</div>
</div>
</section>
<section className="feature-section">
<div className="landing-container">
<div className="section-heading">
<Badge variant="outline" className="heading-pill">
Feature stack
</Badge>
<h2>Đầy đủ công cụ để ghi chú đẹp, rõ, và chuẩn</h2>
<p className="section-subtitle">
Lưới thẻ shadcn với icon lucide, tone màu nhã, hover nổi khối. Mọi thứ đọc nhanh, tập trung vào giá trị.
</p>
</div>
<div className="feature-grid">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div className={`feature-card tone-${feature.tone}`} key={feature.title}>
<div className="feature-icon">
<Icon className="size-5" />
</div>
<div className="feature-content">
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</div>
<span className="feature-tag">shadcn · lucide · smooth</span>
</div>
);
})}
</div>
</div>
</section>
<section className="steps-section">
<div className="landing-container steps-grid">
<div className="section-heading">
<Badge className="heading-pill" variant="secondary">
Flow chuẩn chỉnh
</Badge>
<h2>Từ ý tưởng đến tài liệu đẹp trong 3 bước</h2>
<p className="section-subtitle">
CTA rõ ràng, thẻ glassy, và copy ngắn gọn để người dùng biết phải làm gì tiếp theo.
</p>
<div className="steps-actions">
<Button size="lg" onClick={() => navigate("/app")}>
Vào App Ngay
</Button>
</div>
</div>
<div className="steps-cards">
{steps.map((step, index) => (
<div className="step-card" key={step.title}>
<div className="step-number">{index + 1}</div>
<div>
<p className="step-title">{step.title}</p>
<p className="step-desc">{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="landing-footer">
<div className="landing-container footer-inner">
<div className="footer-brand">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<span className="mark-glow" />
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#landingGradient)" />
<path d="M10 16L14 20L22 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="brand-copy">
<p className="brand-name">BasicNotion</p>
<p className="brand-tagline">Ghi chú sáng, UI xịn.</p>
</div>
</div>
<p className="footer-subtitle">Thiết kế shadcn nhẹ nhàng, gradient êm, tối ưu cho desktop lẫn mobile.</p>
</div>
<div className="footer-actions">
<Button size="lg" className="cta-strong" onClick={() => navigate("/app")}>
Vào App (Test)
</Button>
</div>
</div>
</footer>
</div>
);
};
const ArrowRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.167 10h11.666M10 4.167L15.833 10 10 15.833" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export default Landing;
...@@ -6,18 +6,21 @@ import Spinner from "@/components/Spinner"; ...@@ -6,18 +6,21 @@ import Spinner from "@/components/Spinner";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout"; import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home"; import Home from "@/pages/Home";
import NotFound from "@/pages/NotFound";
const Archived = lazy(() => import("@/pages/Archived")); const Archived = lazy(() => import("@/pages/Archived"));
const Explore = lazy(() => import("@/pages/Explore")); const Explore = lazy(() => import("@/pages/Explore"));
const Inboxes = lazy(() => import("@/pages/Inboxes")); const Inboxes = lazy(() => import("@/pages/Inboxes"));
const MemoDetail = lazy(() => import("@/pages/MemoDetail")); const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
const NotFound = lazy(() => import("@/pages/NotFound"));
const PermissionDenied = lazy(() => import("@/pages/PermissionDenied")); const PermissionDenied = lazy(() => import("@/pages/PermissionDenied"));
const Attachments = lazy(() => import("@/pages/Attachments")); const Attachments = lazy(() => import("@/pages/Attachments"));
const Setting = lazy(() => import("@/pages/Setting")); const Setting = lazy(() => import("@/pages/Setting"));
const UserProfile = lazy(() => import("@/pages/UserProfile")); const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect")); const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
const AuthPage = lazy(() => import("@/pages/Auth")); const AuthPage = lazy(() => import("@/pages/Auth"));
const AuthCallback = lazy(() => import("@/pages/AuthCallback"));
const AuthSsoCallback = lazy(() => import("@/pages/AuthSsoCallback"));
const Landing = lazy(() => import("@/pages/Landing"));
import { ROUTES } from "./routes"; import { ROUTES } from "./routes";
...@@ -42,7 +45,15 @@ const router = createBrowserRouter([ ...@@ -42,7 +45,15 @@ const router = createBrowserRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <NotFound />,
children: [ children: [
// Landing page at root
{ index: true, element: <LazyRoute component={Landing} /> },
{ path: "landing", element: <LazyRoute component={Landing} /> },
// Auth page (separate from main app - no sidebar)
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> },
{ path: "auth/callback", element: <LazyRoute component={AuthCallback} /> },
{ path: "auth/sso-callback", element: <LazyRoute component={AuthSsoCallback} /> },
{ {
path: Routes.ROOT, path: Routes.ROOT,
element: <RootLayout />, element: <RootLayout />,
...@@ -51,15 +62,14 @@ const router = createBrowserRouter([ ...@@ -51,15 +62,14 @@ const router = createBrowserRouter([
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ path: "", element: <Home /> }, { path: "", element: <Home /> },
{ path: Routes.EXPLORE, element: <LazyRoute component={Explore} /> }, { path: "explore", element: <LazyRoute component={Explore} /> },
{ path: Routes.ARCHIVED, element: <LazyRoute component={Archived} /> },
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> }, { path: "u/:username", element: <LazyRoute component={UserProfile} /> },
], ],
}, },
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> }, { path: "attachments", element: <LazyRoute component={Attachments} /> },
{ path: Routes.ATTACHMENTS, element: <LazyRoute component={Attachments} /> }, { path: "archived", element: <LazyRoute component={Archived} /> },
{ path: Routes.INBOX, element: <LazyRoute component={Inboxes} /> }, { path: "inbox", element: <LazyRoute component={Inboxes} /> },
{ path: Routes.SETTING, element: <LazyRoute component={Setting} /> }, { path: "setting", element: <LazyRoute component={Setting} /> },
{ path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> }, { path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> },
// Redirect old path to new path // Redirect old path to new path
{ path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> }, { path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> },
...@@ -68,6 +78,7 @@ const router = createBrowserRouter([ ...@@ -68,6 +78,7 @@ const router = createBrowserRouter([
{ path: "*", element: <LazyRoute component={NotFound} /> }, { path: "*", element: <LazyRoute component={NotFound} /> },
], ],
}, },
{ path: "*", element: <LazyRoute component={NotFound} /> },
], ],
}, },
]); ]);
......
export const ROUTES = { export const ROUTES = {
ROOT: "/", ROOT: "/app",
ATTACHMENTS: "/attachments", LANDING: "/",
INBOX: "/inbox", AUTH: "/auth",
ARCHIVED: "/archived", ATTACHMENTS: "/app/attachments",
SETTING: "/setting", INBOX: "/app/inbox",
EXPLORE: "/explore", ARCHIVED: "/app/archived",
SETTING: "/app/setting",
EXPLORE: "/app/explore",
} as const; } as const;
export type RouteKey = keyof typeof ROUTES; export type RouteKey = keyof typeof ROUTES;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment