small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:01 +02:00
parent 954a1a1080
commit c6a99c7e21
214 changed files with 40234 additions and 2825 deletions
+6 -3
View File
@@ -6,7 +6,6 @@ WORKDIR /app
# Accept build arguments for VITE environment variables
ARG VITE_DEMO_MODE=false
ARG VITE_API_URL=http://localhost:8080
ARG VITE_OAUTH_SERVICE_URL=https://oauth.trackeep.org
# Copy package files
COPY frontend/package*.json ./frontend/
@@ -17,8 +16,7 @@ COPY frontend/ ./frontend/
# Create a .env.production file with build arguments
RUN cd frontend && echo "VITE_DEMO_MODE=${VITE_DEMO_MODE}" >> .env.production && \
echo "VITE_API_URL=${VITE_API_URL}" >> .env.production && \
echo "VITE_OAUTH_SERVICE_URL=${VITE_OAUTH_SERVICE_URL}" >> .env.production
echo "VITE_API_URL=${VITE_API_URL}" >> .env.production
# Build the application (frontend only)
RUN cd frontend && npm run build
@@ -29,6 +27,11 @@ FROM nginx:alpine
# Copy built assets from builder stage
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# Fallback branding assets from repo root for clean-clone builds
COPY trackeep.svg /usr/share/nginx/html/trackeep.svg
COPY trackeepfavi.png /usr/share/nginx/html/trackeepfavi.png
COPY trackeepfavi_bg.png /usr/share/nginx/html/trackeepfavi_bg.png
# Copy the entrypoint script
COPY frontend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
-2
View File
@@ -25,7 +25,6 @@ cp /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.orig
# Replace environment variables in the HTML file
sed -i "s|VITE_DEMO_MODE_PLACEHOLDER|$VITE_DEMO_MODE|g" $HTML_FILE
sed -i "s|VITE_API_URL_PLACEHOLDER|$VITE_API_URL|g" $HTML_FILE
sed -i "s|VITE_OAUTH_SERVICE_URL_PLACEHOLDER|$VITE_OAUTH_SERVICE_URL|g" $HTML_FILE
# Find the actual CSS file name and update the stylesheet link
CSS_FILE=$(find /usr/share/nginx/html/assets -name "*.css" | head -1 | xargs basename)
@@ -36,7 +35,6 @@ fi
echo "Environment variables injected:"
echo "VITE_DEMO_MODE=$VITE_DEMO_MODE"
echo "VITE_API_URL=$VITE_API_URL"
echo "VITE_OAUTH_SERVICE_URL=$VITE_OAUTH_SERVICE_URL"
echo "FRONTEND_PORT=$FRONTEND_PORT"
EOF
+2 -4
View File
@@ -14,16 +14,14 @@
// Runtime environment variable injection
window.ENV = {
VITE_DEMO_MODE: 'VITE_DEMO_MODE_PLACEHOLDER',
VITE_API_URL: 'VITE_API_URL_PLACEHOLDER',
VITE_OAUTH_SERVICE_URL: 'VITE_OAUTH_SERVICE_URL_PLACEHOLDER'
VITE_API_URL: 'VITE_API_URL_PLACEHOLDER'
};
// Make them available to import.meta.env by overriding it
if (typeof window !== 'undefined') {
window.importMetaEnv = {
VITE_DEMO_MODE: window.ENV.VITE_DEMO_MODE,
VITE_API_URL: window.ENV.VITE_API_URL,
VITE_OAUTH_SERVICE_URL: window.ENV.VITE_OAUTH_SERVICE_URL
VITE_API_URL: window.ENV.VITE_API_URL
};
}
+1
View File
@@ -22,6 +22,7 @@
"@unocss/reset": "^66.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"lucide-solid": "^0.575.0",
"solid-js": "^1.9.10",
"tailwind-merge": "^3.5.0"
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>

After

Width:  |  Height:  |  Size: 819 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
{
"name": "Trackeep - Your Self-Hosted Productivity & Knowledge Hub",
"short_name": "Trackeep",
"description": "Track, save, and organize everything that matters to you.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#39b9ff",
"orientation": "portrait-primary",
"share_target": {
"action": "/share-target",
"method": "GET",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
},
"icons": [
{
"src": "/trackeep.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/trackeepfavi_bg.png",
"sizes": "any",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<path fill="#39b9ff" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
<rect fill="#39b9ff" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#39b9ff" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
<rect fill="#39b9ff" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -2
View File
@@ -8,7 +8,7 @@ import { Bookmarks } from '@/pages/Bookmarks'
import { Tasks } from '@/pages/Tasks'
import { Files } from '@/pages/Files'
import { Notes } from '@/pages/Notes'
import { AIChat } from '@/pages/AIChat'
import Chat from '@/pages/Chat'
import { Settings } from '@/pages/Settings'
import { Login } from '@/pages/Login'
import { Youtube } from '@/pages/Youtube'
@@ -28,6 +28,7 @@ import { AuthProvider, useAuth } from '@/lib/auth'
import { Search } from '@/pages/Search'
import { Analytics } from '@/pages/Analytics'
import { Messages } from '@/pages/Messages'
import { ShareTarget } from '@/pages/ShareTarget'
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings'
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
import { onMount, createEffect } from 'solid-js'
@@ -134,6 +135,7 @@ function App() {
<Route path="/" component={RootRoute} />
<Route path="/login" component={Login} />
<Route path="/auth/callback" component={AuthCallback} />
<Route path="/share-target" component={ShareTarget} />
<Route path="/app" component={() => (
<ProtectedRoute>
<Layout title="Dashboard">
@@ -207,7 +209,7 @@ function App() {
<Route path="/app/chat" component={() => (
<ProtectedRoute>
<Layout title="AI Chat" fullBleed>
<AIChat />
<Chat />
</Layout>
</ProtectedRoute>
)} />
+6 -2
View File
@@ -3,6 +3,7 @@ import { Sidebar } from './Sidebar'
import { Header } from './Header'
import { AIChatPanel } from './AIChatPanel'
import { IconBrain } from '@tabler/icons-solidjs'
import { isEnvDemoMode } from '@/lib/demo-mode'
export interface LayoutProps {
children: any
@@ -27,8 +28,11 @@ export function Layout(props: LayoutProps) {
// Initialize dark mode from localStorage or system preference
const savedTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
if (isEnvDemoMode()) {
localStorage.setItem('theme', 'dark')
document.documentElement.setAttribute('data-kb-theme', 'dark')
} else if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
document.documentElement.setAttribute('data-kb-theme', 'dark')
} else {
document.documentElement.removeAttribute('data-kb-theme')
@@ -0,0 +1,15 @@
@import "highlight.js/styles/github-dark.css";
.note-renderer {
color: hsl(var(--foreground));
}
.note-renderer .hljs {
background: transparent;
padding: 0;
color: inherit;
}
.note-renderer pre code {
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, monospace;
}
@@ -0,0 +1,498 @@
import hljs from 'highlight.js/lib/common';
import { For, Show, createMemo, createSignal, type JSX } from 'solid-js';
import './NoteContentRenderer.css';
type NoteKind = 'markdown' | 'plain' | 'html';
type NoteBlock =
| { type: 'heading'; level: 1 | 2 | 3 | 4; text: string }
| { type: 'paragraph'; text: string }
| { type: 'bullet-list'; items: string[] }
| { type: 'ordered-list'; items: string[] }
| { type: 'task-list'; items: Array<{ text: string; checked: boolean; taskIndex: number }> }
| { type: 'quote'; text: string }
| { type: 'code'; code: string; language?: string };
interface NoteContentRendererProps {
content: string;
kind: NoteKind;
preview?: boolean;
maxBlocks?: number;
onToggleTask?: (taskIndex: number, nextChecked: boolean) => void;
}
type SupportedCodeLanguage =
| 'auto'
| 'bash'
| 'go'
| 'html'
| 'javascript'
| 'json'
| 'markdown'
| 'plaintext'
| 'typescript'
| 'xml';
const CODE_LANGUAGE_OPTIONS: Array<{ value: SupportedCodeLanguage; label: string }> = [
{ value: 'auto', label: 'Auto detect' },
{ value: 'json', label: 'JSON' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'go', label: 'Go' },
{ value: 'bash', label: 'Bash / Shell' },
{ value: 'html', label: 'HTML' },
{ value: 'markdown', label: 'Markdown' },
{ value: 'plaintext', label: 'Plain text' },
];
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
auto: 'auto',
bash: 'bash',
console: 'bash',
go: 'go',
golang: 'go',
html: 'html',
javascript: 'javascript',
js: 'javascript',
json: 'json',
markdown: 'markdown',
md: 'markdown',
plaintext: 'plaintext',
shell: 'bash',
sh: 'bash',
text: 'plaintext',
ts: 'typescript',
typescript: 'typescript',
xml: 'xml',
};
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const normalizeCodeLanguage = (value?: string): SupportedCodeLanguage => {
if (!value) return 'auto';
return LANGUAGE_ALIASES[value.trim().toLowerCase()] || 'auto';
};
const inferCodeLanguage = (code: string, explicitLanguage?: string): SupportedCodeLanguage => {
const normalizedExplicit = normalizeCodeLanguage(explicitLanguage);
if (normalizedExplicit !== 'auto') {
return normalizedExplicit;
}
const trimmed = code.trim();
if (!trimmed) {
return 'plaintext';
}
try {
JSON.parse(trimmed);
return 'json';
} catch {
// not JSON
}
if (/^\s*</.test(trimmed) && /<\/?[a-z][\s\S]*>/i.test(trimmed)) {
return 'html';
}
if (/\bfunc\s+\w+\s*\(|\bfmt\.\w+\(/.test(trimmed)) {
return 'go';
}
if (/^\s*[$#]/m.test(trimmed) || /\b(curl|npm|pnpm|yarn|git)\b/.test(trimmed)) {
return 'bash';
}
if (/\b(interface|type|readonly|as const)\b|:\s*[A-Z][A-Za-z0-9_<>,[\]?| ]*/.test(trimmed)) {
return 'typescript';
}
if (/\b(const|let|var|function|=>|import|export)\b/.test(trimmed)) {
return 'javascript';
}
if (/^#{1,6}\s/m.test(trimmed) || /\[[^\]]+\]\(([^)]+)\)/.test(trimmed)) {
return 'markdown';
}
const autoDetected = hljs.highlightAuto(trimmed, ['json', 'typescript', 'javascript', 'go', 'bash', 'xml']).language;
return normalizeCodeLanguage(autoDetected || 'plaintext');
};
const renderInlineText = (text: string) => {
const normalized = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1 ($2)');
const nodes: Array<string | JSX.Element> = [];
const pattern = /(\[[^\]]+\]\(([^)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*|(https?:\/\/[^\s]+))/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(normalized)) !== null) {
if (match.index > lastIndex) {
nodes.push(normalized.slice(lastIndex, match.index));
}
const token = match[0];
const markdownLink = match[1];
const linkHref = match[2];
const inlineCode = match[3];
const strongText = match[4];
const emphasisText = match[5];
const directUrl = match[6];
if (markdownLink && linkHref) {
const linkMatch = markdownLink.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
const label = linkMatch?.[1] || markdownLink;
const href = linkMatch?.[2] || linkHref;
nodes.push(
<a href={href} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{label}
</a>
);
} else if (inlineCode) {
nodes.push(
<code class="rounded-md border border-border/60 bg-muted/80 px-1.5 py-0.5 font-mono text-[0.92em] text-primary">
{inlineCode}
</code>
);
} else if (strongText) {
nodes.push(<strong class="font-semibold text-foreground">{strongText}</strong>);
} else if (emphasisText) {
nodes.push(<em class="italic">{emphasisText}</em>);
} else {
nodes.push(
<a href={directUrl || token} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{directUrl || token}
</a>
);
}
lastIndex = match.index + token.length;
}
if (lastIndex < normalized.length) {
nodes.push(normalized.slice(lastIndex));
}
return nodes;
};
const parseTextBlocks = (content: string, kind: Exclude<NoteKind, 'html'>): NoteBlock[] => {
const lines = content.replace(/\r\n/g, '\n').split('\n');
const blocks: NoteBlock[] = [];
let index = 0;
let taskIndex = 0;
const isMarkdown = kind === 'markdown';
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
const codeFenceMatch = isMarkdown ? trimmed.match(/^```([\w-]+)?\s*$/) : null;
if (codeFenceMatch) {
const codeLines: string[] = [];
const language = codeFenceMatch[1];
index += 1;
while (index < lines.length && !lines[index].trim().match(/^```$/)) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ type: 'code', code: codeLines.join('\n'), language });
continue;
}
const headingMatch = isMarkdown ? line.match(/^(#{1,4})\s+(.*)$/) : null;
if (headingMatch) {
blocks.push({
type: 'heading',
level: headingMatch[1].length as 1 | 2 | 3 | 4,
text: headingMatch[2].trim(),
});
index += 1;
continue;
}
if (/^- \[( |x|X)\]\s+/.test(trimmed)) {
const items: Array<{ text: string; checked: boolean; taskIndex: number }> = [];
while (index < lines.length) {
const taskMatch = lines[index].trim().match(/^- \[( |x|X)\]\s+(.*)$/);
if (!taskMatch) break;
items.push({
text: taskMatch[2],
checked: taskMatch[1].toLowerCase() === 'x',
taskIndex,
});
taskIndex += 1;
index += 1;
}
blocks.push({ type: 'task-list', items });
continue;
}
if (/^- /.test(trimmed)) {
const items: string[] = [];
while (index < lines.length) {
const listMatch = lines[index].trim().match(/^- (.*)$/);
if (!listMatch || /^- \[( |x|X)\]\s+/.test(lines[index].trim())) break;
items.push(listMatch[1]);
index += 1;
}
blocks.push({ type: 'bullet-list', items });
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (index < lines.length) {
const listMatch = lines[index].trim().match(/^\d+\.\s+(.*)$/);
if (!listMatch) break;
items.push(listMatch[1]);
index += 1;
}
blocks.push({ type: 'ordered-list', items });
continue;
}
if (/^>\s+/.test(trimmed)) {
const quoteLines: string[] = [];
while (index < lines.length) {
const quoteMatch = lines[index].trim().match(/^>\s+(.*)$/);
if (!quoteMatch) break;
quoteLines.push(quoteMatch[1]);
index += 1;
}
blocks.push({ type: 'quote', text: quoteLines.join(' ') });
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length) {
const paragraphLine = lines[index];
const paragraphTrimmed = paragraphLine.trim();
if (!paragraphTrimmed) break;
if (isMarkdown && /^(#{1,4})\s+/.test(paragraphLine)) break;
if (isMarkdown && /^```/.test(paragraphTrimmed)) break;
if (/^- \[( |x|X)\]\s+/.test(paragraphTrimmed)) break;
if (/^- /.test(paragraphTrimmed)) break;
if (/^\d+\.\s+/.test(paragraphTrimmed)) break;
if (/^>\s+/.test(paragraphTrimmed)) break;
paragraphLines.push(paragraphTrimmed);
index += 1;
}
if (paragraphLines.length > 0) {
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') });
continue;
}
index += 1;
}
return blocks;
};
const CodeBlock = (props: {
code: string;
initialLanguage?: string;
preview?: boolean;
}) => {
const autoDetectedLanguage = createMemo(() => inferCodeLanguage(props.code, props.initialLanguage));
const [selectedLanguage, setSelectedLanguage] = createSignal<SupportedCodeLanguage>(
normalizeCodeLanguage(props.initialLanguage)
);
const effectiveLanguage = createMemo<SupportedCodeLanguage>(() => {
const selected = selectedLanguage();
if (selected === 'auto') {
return autoDetectedLanguage();
}
return selected;
});
const highlightedHtml = createMemo(() => {
const activeLanguage = effectiveLanguage();
if (activeLanguage === 'plaintext') {
return escapeHtml(props.code);
}
try {
const languageForHighlight = activeLanguage === 'html' ? 'xml' : activeLanguage;
return hljs.highlight(props.code, { language: languageForHighlight }).value;
} catch {
return escapeHtml(props.code);
}
});
const displayLanguageLabel = createMemo(() => {
const activeLanguage = effectiveLanguage();
const match = CODE_LANGUAGE_OPTIONS.find((option) => option.value === activeLanguage);
return match?.label || activeLanguage;
});
return (
<div
class="overflow-hidden rounded-2xl border border-border/70 bg-[#0f1724] shadow-[0_20px_40px_-28px_rgba(15,23,36,0.9)]"
onClick={(event) => event.stopPropagation()}
>
<div class="flex items-center justify-between gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3">
<div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-300">
<span class="rounded-full border border-white/12 bg-white/8 px-2.5 py-1">{displayLanguageLabel()}</span>
<Show when={selectedLanguage() === 'auto'}>
<span class="text-[11px] tracking-[0.14em] text-slate-500">Auto</span>
</Show>
</div>
<Show when={!props.preview}>
<select
value={selectedLanguage()}
class="rounded-lg border border-white/12 bg-slate-950/70 px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:ring-2 focus:ring-primary"
onClick={(event) => event.stopPropagation()}
onChange={(event) => setSelectedLanguage(event.currentTarget.value as SupportedCodeLanguage)}
>
<For each={CODE_LANGUAGE_OPTIONS}>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</select>
</Show>
</div>
<pre class={`overflow-x-auto px-4 py-4 text-sm leading-6 text-slate-100 ${props.preview ? 'max-h-72' : ''}`}>
<code class={`hljs language-${effectiveLanguage()}`} innerHTML={highlightedHtml()} />
</pre>
</div>
);
};
export const NoteContentRenderer = (props: NoteContentRendererProps) => {
const blocks = createMemo(() => {
if (props.kind === 'html') {
return [] as NoteBlock[];
}
return parseTextBlocks(props.content, props.kind);
});
const visibleBlocks = createMemo(() => {
if (!props.preview) {
return blocks();
}
return blocks().slice(0, props.maxBlocks ?? 4);
});
return (
<Show
when={props.kind !== 'html'}
fallback={
<div
class="note-renderer prose prose-invert max-w-none text-sm leading-6 text-foreground"
onClick={(event) => event.stopPropagation()}
innerHTML={props.content}
/>
}
>
<div class={`note-renderer space-y-4 ${props.preview ? 'overflow-hidden' : ''}`}>
<For each={visibleBlocks()}>
{(block) => (
<Show
when={block.type === 'heading'}
fallback={
<Show
when={block.type === 'paragraph'}
fallback={
<Show
when={block.type === 'bullet-list'}
fallback={
<Show
when={block.type === 'ordered-list'}
fallback={
<Show
when={block.type === 'task-list'}
fallback={
<Show
when={block.type === 'quote'}
fallback={
<CodeBlock
code={(block as Extract<NoteBlock, { type: 'code' }>).code}
initialLanguage={(block as Extract<NoteBlock, { type: 'code' }>).language}
preview={props.preview}
/>
}
>
<blockquote class="rounded-r-xl border-l-4 border-primary/60 bg-muted/40 px-4 py-3 text-sm italic text-muted-foreground">
{renderInlineText((block as Extract<NoteBlock, { type: 'quote' }>).text)}
</blockquote>
</Show>
}
>
<div class="space-y-2" onClick={(event) => event.stopPropagation()}>
<For each={(block as Extract<NoteBlock, { type: 'task-list' }>).items}>
{(item) => (
<label class="flex items-start gap-3 rounded-xl border border-border/60 bg-muted/30 px-3 py-2.5">
<input
type="checkbox"
checked={item.checked}
class="mt-0.5 h-4 w-4 cursor-pointer accent-[hsl(var(--primary))]"
disabled={!props.onToggleTask}
onClick={(event) => event.stopPropagation()}
onChange={(event) => props.onToggleTask?.(item.taskIndex, event.currentTarget.checked)}
/>
<span class={`text-sm leading-6 ${item.checked ? 'text-muted-foreground line-through' : 'text-foreground'}`}>
{renderInlineText(item.text)}
</span>
</label>
)}
</For>
</div>
</Show>
}
>
<ol class="list-decimal space-y-2 pl-5 text-sm leading-6 text-foreground">
<For each={(block as Extract<NoteBlock, { type: 'ordered-list' }>).items}>
{(item) => <li>{renderInlineText(item)}</li>}
</For>
</ol>
</Show>
}
>
<ul class="list-disc space-y-2 pl-5 text-sm leading-6 text-foreground">
<For each={(block as Extract<NoteBlock, { type: 'bullet-list' }>).items}>
{(item) => <li>{renderInlineText(item)}</li>}
</For>
</ul>
</Show>
}
>
<p class="text-sm leading-7 text-muted-foreground">{renderInlineText((block as Extract<NoteBlock, { type: 'paragraph' }>).text)}</p>
</Show>
}
>
{(() => {
const heading = block as Extract<NoteBlock, { type: 'heading' }>;
const className = {
1: 'text-xl font-semibold tracking-tight text-foreground',
2: 'text-lg font-semibold tracking-tight text-foreground',
3: 'text-base font-semibold text-foreground',
4: 'text-sm font-semibold uppercase tracking-[0.12em] text-muted-foreground',
}[heading.level];
return <div class={className}>{renderInlineText(heading.text)}</div>;
})()}
</Show>
)}
</For>
</div>
</Show>
);
};
+52 -1
View File
@@ -39,6 +39,14 @@ interface GitHubActivityProps {
customEvents?: ActivityEvent[];
hideHeader?: boolean;
fullWidth?: boolean;
externalData?: {
contributions: Array<{
date: string;
count: number;
level: number;
}>;
total_count: number;
};
}
export const GitHubActivity = (props: GitHubActivityProps) => {
@@ -141,11 +149,54 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
};
onMount(() => {
if (isDemoMode()) {
// Listen for external GitHub activity data
const handleGitHubActivityData = (event: CustomEvent) => {
const data = event.detail as {
contributions: Array<{
date: string;
count: number;
level: number;
}>;
total_count: number;
};
if (data.contributions && data.contributions.length > 0) {
const activityData = data.contributions.map(c => ({
date: c.date,
count: c.count,
level: c.level
}));
setActivities(activityData);
setStats(prev => ({
...prev,
totalContributions: data.total_count
}));
}
};
window.addEventListener('githubActivityData', handleGitHubActivityData as EventListener);
if (props.externalData) {
// Use provided external data
const activityData = props.externalData.contributions.map(c => ({
date: c.date,
count: c.count,
level: c.level
}));
setActivities(activityData);
setStats(prev => ({
...prev,
totalContributions: props.externalData!.total_count
}));
} else if (isDemoMode()) {
setDemoData();
} else {
setEmptyData();
}
return () => {
window.removeEventListener('githubActivityData', handleGitHubActivityData as EventListener);
};
});
const getMonthLabels = () => {
+36 -21
View File
@@ -25,26 +25,39 @@ const ToastItem = (props: ToastProps) => {
const getIcon = () => {
switch (props.toast.type) {
case 'success':
return <IconCheck class="h-5 w-5 text-green-500" />;
return <IconCheck class="h-4 w-4 text-emerald-500" />;
case 'error':
return <IconAlertTriangle class="h-5 w-5 text-destructive" />;
return <IconAlertTriangle class="h-4 w-4 text-destructive" />;
case 'warning':
return <IconAlertTriangle class="h-5 w-5 text-warning" />;
return <IconAlertTriangle class="h-4 w-4 text-amber-500" />;
case 'info':
return <IconInfoCircle class="h-5 w-5 text-primary" />;
return <IconInfoCircle class="h-4 w-4 text-primary" />;
}
};
const getBackgroundColor = () => {
const getToneClasses = () => {
switch (props.toast.type) {
case 'success':
return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800';
return 'border-emerald-500/20 bg-emerald-500/8';
case 'error':
return 'bg-destructive/10 border-destructive/20';
return 'border-destructive/25 bg-destructive/8';
case 'warning':
return 'bg-warning/10 border-warning/20';
return 'border-amber-500/25 bg-amber-500/8';
case 'info':
return 'bg-primary/10 border-primary/20';
return 'border-primary/25 bg-primary/8';
}
};
const getIconSurface = () => {
switch (props.toast.type) {
case 'success':
return 'bg-emerald-500/12 ring-1 ring-emerald-500/15';
case 'error':
return 'bg-destructive/12 ring-1 ring-destructive/15';
case 'warning':
return 'bg-amber-500/12 ring-1 ring-amber-500/15';
case 'info':
return 'bg-primary/12 ring-1 ring-primary/15';
}
};
@@ -57,34 +70,34 @@ const ToastItem = (props: ToastProps) => {
return (
<div
class={`transform transition-all duration-300 ease-in-out ${
isVisible() ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
isVisible() ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
}`}
>
<div
class={`max-w-sm w-full ${getBackgroundColor()} border rounded-lg shadow-lg p-4 mb-4`}
class={`max-w-sm w-full rounded-2xl border px-4 py-3 shadow-2xl shadow-black/10 backdrop-blur-xl ${getToneClasses()}`}
role="alert"
>
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="flex items-start gap-3">
<div class={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${getIconSurface()}`}>
{getIcon()}
</div>
<div class="ml-3 w-0 flex-1">
<p class="text-sm font-medium text-foreground">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">
{props.toast.title}
</p>
{props.toast.message && (
<p class="mt-1 text-sm text-muted-foreground">
<p class="mt-1 text-sm leading-5 text-muted-foreground">
{props.toast.message}
</p>
)}
</div>
<div class="ml-4 flex-shrink-0 flex">
<div class="flex shrink-0">
<button
class="inline-flex text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary rounded"
class="inline-flex h-8 w-8 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
onClick={handleClose}
>
<span class="sr-only">Close</span>
<IconX class="h-5 w-5" />
<IconX class="h-4 w-4" />
</button>
</div>
</div>
@@ -128,9 +141,11 @@ export const ToastContainer = () => {
};
return (
<div class="fixed top-4 right-4 z-50 space-y-4">
<div class="pointer-events-none fixed left-1/2 top-4 z-50 flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 flex-col gap-3 sm:left-auto sm:right-4 sm:top-4 sm:w-full sm:translate-x-0">
{toasts().map(toast => (
<ToastItem toast={toast} onClose={handleClose} />
<div class="pointer-events-auto">
<ToastItem toast={toast} onClose={handleClose} />
</div>
))}
</div>
);
+40 -74
View File
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/Button';
import { ModalPortal } from '@/components/ui/ModalPortal';
import { For, Show, createEffect } from 'solid-js';
import { Show } from 'solid-js';
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
interface Note {
@@ -36,34 +37,39 @@ interface ViewNoteModalProps {
export const ViewNoteModal = (props: ViewNoteModalProps) => {
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
// Make the function available globally for checkbox onchange handlers
createEffect(() => {
(window as any).updateViewNoteContent = (checkbox: HTMLInputElement) => {
if (props.note && props.onUpdateNote) {
const lines = props.note.content.split('\n');
let checkboxCount = 0;
const checkboxElements = document.querySelectorAll('.note-content input[type="checkbox"]');
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
const updatedLines = lines.map(line => {
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
const checkedMatch = line.match(/^- \[x\] (.*)$/);
if (uncheckedMatch || checkedMatch) {
if (checkboxCount === checkboxIndex) {
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
return checkbox.checked ? `- [x] ${text}` : `- [ ] ${text}`;
}
checkboxCount++;
}
const getNoteKind = () => {
if (props.note?.isHtml) return 'html' as const;
if (props.note?.isMarkdown) return 'markdown' as const;
return 'plain' as const;
};
const updateNoteCheckbox = (checkboxIndex: number, nextChecked: boolean) => {
if (!props.note || !props.onUpdateNote) return;
let taskCounter = 0;
const nextContent = props.note.content
.split('\n')
.map((line) => {
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
const checkedMatch = line.match(/^- \[(x|X)\] (.*)$/);
if (!uncheckedMatch && !checkedMatch) {
return line;
});
props.onUpdateNote(props.note.id, updatedLines.join('\n'));
}
};
});
}
const currentIndex = taskCounter;
taskCounter += 1;
if (currentIndex !== checkboxIndex) {
return line;
}
const label = uncheckedMatch?.[1] || checkedMatch?.[2] || '';
return nextChecked ? `- [x] ${label}` : `- [ ] ${label}`;
})
.join('\n');
props.onUpdateNote(props.note.id, nextContent);
};
return (
<ModalPortal>
@@ -185,52 +191,12 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
)}
{/* Note Content */}
<div class="prose prose-invert max-w-none note-content">
{props.note.isHtml ? (
<div
class="text-[#fafafa] leading-relaxed"
innerHTML={props.note.content}
/>
) : props.note.isMarkdown ? (
<div class="text-[#fafafa] leading-relaxed">
{/* Enhanced Markdown rendering with image support */}
<For each={props.note.content
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2">$1</h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-md font-bold mb-2">$1</h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
.replace(/\n\n/g, '</p><p class="mb-4">')
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-4 italic text-[#aaa] mb-4">$1</blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded mb-4" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'block\';" /><div style="display:none;" class="text-[#666] italic mb-4">Image: $1 ($2)</div>')
.split('</p><p class="mb-4">')}>
{(line) => (
<div innerHTML={line.startsWith('<') ? line : `<p class="mb-4">${line}</p>`} />
)}
</For>
</div>
) : (
<div class="text-[#fafafa] whitespace-pre-wrap leading-relaxed">
{/* Auto-detect and render URLs and basic formatting */}
{props.note.content
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
.split('\n').map((line) => (
<div innerHTML={line || '<br />'} />
))}
</div>
)}
<div class="max-w-none note-content">
<NoteContentRenderer
content={props.note.content}
kind={getNoteKind()}
onToggleTask={props.onUpdateNote ? updateNoteCheckbox : undefined}
/>
</div>
{/* Metadata */}
-2
View File
@@ -42,7 +42,6 @@ declare module "*.bmp" {
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_DEMO_MODE?: string;
readonly VITE_OAUTH_SERVICE_URL?: string;
}
interface ImportMeta {
@@ -53,7 +52,6 @@ interface Window {
importMetaEnv?: {
VITE_API_URL?: string;
VITE_DEMO_MODE?: string;
VITE_OAUTH_SERVICE_URL?: string;
};
}
+7
View File
@@ -5,6 +5,13 @@ import 'uno.css'
import './index.css'
import App from './App.tsx'
// Clear demo mode data if demo mode is disabled
import { isDemoMode, clearDemoMode } from './lib/demo-mode'
if (!isDemoMode()) {
clearDemoMode()
}
const root = document.getElementById('root')
render(() => <App />, root!)
+1
View File
@@ -134,6 +134,7 @@ export const AuthProvider: ParentComponent = (props) => {
localStorage.setItem('token', mockToken);
localStorage.setItem('trackeep_user', JSON.stringify(mockUser));
localStorage.setItem('user', JSON.stringify(mockUser));
localStorage.setItem('theme', 'dark');
// Apply theme
document.documentElement.setAttribute('data-kb-theme', 'dark');
+18
View File
@@ -14,6 +14,15 @@ export const isEnvDemoMode = (): boolean => {
const buildTimeResult = import.meta.env.VITE_DEMO_MODE === 'true';
const result = runtimeResult || importMetaEnvResult || buildTimeResult;
// Debug logging to help troubleshoot demo mode issues
console.debug('Demo mode check:', {
runtimeEnv: (window as any).ENV?.VITE_DEMO_MODE,
importMetaEnv: (window as any).importMetaEnv?.VITE_DEMO_MODE,
buildTimeEnv: import.meta.env.VITE_DEMO_MODE,
finalResult: result
});
return result;
};
@@ -54,6 +63,15 @@ export const clearDemoMode = (): void => {
localStorage.removeItem('trackeep_token');
localStorage.removeItem('trackeep_user');
}
// Clear any sessionStorage demo data
const sessionToken = sessionStorage.getItem('token');
if (sessionToken && sessionToken.includes('demo-token')) {
sessionStorage.removeItem('token');
sessionStorage.removeItem('user');
}
console.debug('Demo mode data cleared from storage');
};
// Set demo mode (no-op - environment variable only)
+5 -3
View File
@@ -1,13 +1,15 @@
import { getApiV1BaseUrl } from '@/lib/api-url';
export const getOAuthCallbackUrl = (): string => {
export const getAuthCallbackUrl = (): string => {
return new URL('/auth/callback', window.location.origin).toString();
};
export const startGitHubOAuth = (): void => {
export const startGitHubSignIn = (): void => {
const apiBase = getApiV1BaseUrl();
const frontendRedirect = getOAuthCallbackUrl();
const frontendRedirect = getAuthCallbackUrl();
window.location.href =
`${apiBase}/auth/github?frontend_redirect=${encodeURIComponent(frontendRedirect)}`;
};
export const startGitHubOAuth = startGitHubSignIn;
-12
View File
@@ -211,19 +211,7 @@ export const AIChat = () => {
</div>
</div>
{/* Model Switcher */}
<div class="flex items-center gap-3">
<select
value={selectedModel()}
onChange={(e) => setSelectedModel(e.target.value)}
class="px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="standard">Standard Model</option>
<option value="advanced">Advanced Model</option>
<option value="fast">Fast Model</option>
<option value="creative">Creative Model</option>
</select>
{/* View Switcher */}
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
<button
+70 -9
View File
@@ -35,6 +35,23 @@ interface Bookmark {
screenshot_original?: string;
}
interface VideoBookmarkTag {
name: string;
}
interface VideoBookmark {
id: number;
video_id: string;
title: string;
description: string;
channel: string;
thumbnail: string;
url: string;
duration: string;
publishedAt: string;
tags: VideoBookmarkTag[];
}
export const Bookmarks = () => {
const getBookmarkInitial = (title?: string) => {
const safeTitle = typeof title === 'string' ? title.trim() : '';
@@ -91,7 +108,7 @@ export const Bookmarks = () => {
};
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([]);
const [videoBookmarks, setVideoBookmarks] = createSignal<any[]>([]);
const [videoBookmarks, setVideoBookmarks] = createSignal<VideoBookmark[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
const [searchTerm, setSearchTerm] = createSignal('');
@@ -129,7 +146,7 @@ export const Bookmarks = () => {
// Load video bookmarks
try {
const videosResponse = await fetch(`${API_BASE_URL}/youtube/videos`, {
const videosResponse = await fetch(`${API_BASE_URL}/video-bookmarks`, {
headers: {
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
},
@@ -137,7 +154,8 @@ export const Bookmarks = () => {
if (videosResponse.ok) {
const videosData = await videosResponse.json();
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
const items = Array.isArray(videosData?.bookmarks) ? videosData.bookmarks : [];
setVideoBookmarks(items.map(adaptVideoBookmarkFromApi));
} else {
setVideoBookmarks([]);
}
@@ -335,24 +353,34 @@ export const Bookmarks = () => {
const handleVideoSubmit = async (video: any) => {
try {
// Use the YouTube API to add video
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/youtube/video-details`, {
const response = await fetch(`${API_BASE_URL}/video-bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
},
body: JSON.stringify({ video_id: video.video_id })
body: JSON.stringify({
url: video.url,
description: video.description,
tags: Array.isArray(video.tags) ? video.tags.join(',') : '',
is_favorite: false,
})
});
if (response.ok) {
console.log('Video added:', video);
const data = await response.json();
const created = data?.bookmark ? adaptVideoBookmarkFromApi(data.bookmark) : null;
if (created) {
setVideoBookmarks(prev => [created, ...prev]);
}
} else {
console.warn('Video save endpoint returned non-OK status');
const error = await response.json().catch(() => ({}));
throw new Error(error.error || 'Failed to save video bookmark');
}
setShowVideoModal(false);
} catch (error) {
console.error('Failed to add video:', error);
alert(error instanceof Error ? error.message : 'Failed to add video bookmark');
setShowVideoModal(false);
}
};
@@ -693,7 +721,20 @@ export const Bookmarks = () => {
<DropdownMenuItem
onClick={() => {
if (confirm('Are you sure you want to delete this video bookmark?')) {
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
fetch(`${API_BASE_URL}/video-bookmarks/${video.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
}
}).then((response) => {
if (!response.ok) {
throw new Error('Failed to delete video bookmark');
}
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
}).catch((error) => {
console.error('Failed to delete video bookmark:', error);
alert(error instanceof Error ? error.message : 'Failed to delete video bookmark');
});
}
}}
icon={IconTrash}
@@ -727,3 +768,23 @@ export const Bookmarks = () => {
</div>
);
};
const adaptVideoBookmarkFromApi = (raw: any): VideoBookmark => {
const rawTags = typeof raw?.tags === 'string'
? raw.tags.split(',').map((tag: string) => tag.trim()).filter(Boolean)
: Array.isArray(raw?.tags)
? raw.tags.map((tag: any) => typeof tag === 'string' ? tag : tag?.name).filter(Boolean)
: [];
return {
id: raw.id,
video_id: raw.video_id,
title: raw.title || raw.url || 'Untitled video',
description: raw.description || '',
channel: raw.channel || 'Unknown channel',
thumbnail: raw.thumbnail || '',
url: raw.url,
duration: raw.duration || 'Unknown',
publishedAt: raw.published_at || raw.created_at || 'Unknown',
tags: rawTags.map((name: string) => ({ name })),
};
};
@@ -4,6 +4,7 @@ import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { toast } from '../components/ui/Toast';
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
import { getApiV1BaseUrl } from '@/lib/api-url';
interface APIKey {
id: number;
@@ -41,6 +42,7 @@ interface Example {
}
const BrowserExtensionSettings = () => {
const apiBaseUrl = getApiV1BaseUrl();
const [apiKeys, setApiKeys] = createSignal<APIKey[]>([]);
const [extensions, setExtensions] = createSignal<BrowserExtension[]>([]);
const [loading, setLoading] = createSignal(false);
@@ -175,7 +177,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
const loadApiKeys = async () => {
try {
const response = await fetch('/api/v1/browser-extension/api-keys', {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
@@ -194,7 +196,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
const loadExtensions = async () => {
try {
const response = await fetch('/api/v1/browser-extension/extensions', {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
@@ -224,7 +226,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
setLoading(true);
try {
const response = await fetch('/api/v1/browser-extension/api-keys/generate', {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -261,7 +263,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
}
try {
const response = await fetch(`/api/v1/browser-extension/api-keys/${keyId}`, {
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
@@ -286,7 +288,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
}
try {
const response = await fetch(`/api/v1/browser-extension/extensions/${extensionId}`, {
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
+31 -1
View File
@@ -127,6 +127,36 @@ interface GitHubActivityEvent {
type: 'push' | 'commit' | 'bookmark' | 'note';
}
const demoGithubActivityEvents: GitHubActivityEvent[] = [
{
id: 'github-demo-1',
title: 'Pushed 4 commits to trackeep/frontend',
date: '2 hours ago',
repo: 'trackeep',
action: 'pushed',
link: 'https://github.com/tdvorak/trackeep',
type: 'push',
},
{
id: 'github-demo-2',
title: 'Commit: refine demo-mode note rendering',
date: 'Yesterday',
repo: 'trackeep',
action: 'committed',
link: 'https://github.com/tdvorak/trackeep',
type: 'commit',
},
{
id: 'github-demo-3',
title: 'Pushed 2 commits to docker-homelab',
date: '3 days ago',
repo: 'docker-homelab',
action: 'pushed',
link: 'https://github.com/tdvorak/docker-homelab',
type: 'push',
},
];
const createEmptyStats = (): QuickStats => ({
totalDocuments: 0,
totalBookmarks: 0,
@@ -268,7 +298,7 @@ export const Dashboard = () => {
if (isDemoMode()) {
setDashboardStats(getMockStats());
setDocuments(getMockDocuments());
setGithubActivityEvents([]);
setGithubActivityEvents(demoGithubActivityEvents);
const mockActivities = getMockActivities();
const filteredActivities = mockActivities
+35 -19
View File
@@ -6,7 +6,7 @@ import { FileUpload } from '@/components/ui/FileUpload';
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { startGitHubOAuth } from '@/lib/oauth';
import { startGitHubSignIn } from '@/lib/oauth';
import { isDemoMode } from '@/lib/demo-mode';
import { getMockDocuments } from '@/lib/mockData';
import {
@@ -78,6 +78,7 @@ interface GitHubAppInstallation {
interface GitHubAppStatus {
app_slug: string;
install_enabled: boolean;
sign_in_configured: boolean;
credentials_configured: boolean;
installed: boolean;
installation?: GitHubAppInstallation;
@@ -104,6 +105,7 @@ type FilesTab = 'files' | 'github-backups';
const defaultGitHubAppStatus: GitHubAppStatus = {
app_slug: '',
install_enabled: false,
sign_in_configured: false,
credentials_configured: false,
installed: false,
};
@@ -258,12 +260,12 @@ export const Files = () => {
return;
}
let oauthConnected = false;
let githubSignInConnected = false;
const meResponse = await fetchWithAuth('/auth/me');
if (meResponse.ok) {
const meData = await meResponse.json();
oauthConnected = Boolean(meData?.user?.github_id);
setGitHubOAuthConnected(oauthConnected);
githubSignInConnected = Boolean(meData?.user?.github_id);
setGitHubOAuthConnected(githubSignInConnected);
setGitHubUsername(typeof meData?.user?.username === 'string' ? meData.user.username : '');
} else {
setGitHubOAuthConnected(false);
@@ -276,6 +278,7 @@ export const Files = () => {
setGitHubAppStatus({
app_slug: appStatusData.app_slug || '',
install_enabled: Boolean(appStatusData.install_enabled),
sign_in_configured: Boolean(appStatusData.sign_in_configured),
credentials_configured: Boolean(appStatusData.credentials_configured),
installed: Boolean(appStatusData.installed),
installation: appStatusData.installation,
@@ -296,7 +299,7 @@ export const Files = () => {
}
let reposResponse: Response | null = null;
if (oauthConnected) {
if (githubSignInConnected) {
reposResponse = await fetchWithAuth('/github/repos');
} else if (gitHubAppStatus().installed && gitHubAppStatus().credentials_configured) {
reposResponse = await fetchWithAuth('/github/app/repos');
@@ -352,7 +355,7 @@ export const Files = () => {
setGitHubError('');
setGitHubMessage('');
const source = gitHubOAuthConnected() ? 'oauth' : 'github_app';
const source = gitHubOAuthConnected() ? 'github_user' : 'github_app';
const response = await fetchWithAuth('/github/backups', {
method: 'POST',
headers: {
@@ -506,11 +509,11 @@ export const Files = () => {
const selectAllRepos = () => setSelectedRepos(gitHubRepos().map(repo => repo.full_name));
const clearSelectedRepos = () => setSelectedRepos([]);
const formatSourceLabel = (source: string) => source === 'github_app' ? 'GitHub App' : 'OAuth';
const formatSourceLabel = (source: string) => source === 'github_app' ? 'GitHub App' : 'GitHub Sign-In';
return (
<div class="p-6 space-y-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold text-foreground">Files</h1>
<p class="text-muted-foreground mt-1">
@@ -518,7 +521,12 @@ export const Files = () => {
</p>
</div>
<Show when={activeTab() === 'files'}>
<Button onClick={() => setShowUploadModal(true)} haptic="impact">
<Button
onClick={() => {
setShowUploadModal(true);
haptics.impact();
}}
>
<IconUpload class="size-4 mr-2" />
Upload File
</Button>
@@ -743,11 +751,10 @@ export const Files = () => {
<Show when={activeTab() === 'github-backups'}>
<div class="space-y-6">
<Card class="relative overflow-hidden p-6 border border-primary/25 bg-gradient-to-r from-primary/12 via-primary/5 to-transparent">
<div class="absolute -top-8 -right-8 h-32 w-32 rounded-full bg-primary/15 blur-2xl" />
<div class="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<Card class="p-6 border border-border bg-card">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div>
<p class="text-sm uppercase tracking-wide text-primary/80 font-semibold mb-2">Repository Storage</p>
<p class="text-sm uppercase tracking-wide text-muted-foreground font-semibold mb-2">Repository Storage</p>
<h2 class="text-2xl font-bold text-foreground mb-1">GitHub Backups</h2>
<p class="text-sm text-muted-foreground">
Select repositories and store mirrored backups locally for resilient archival.
@@ -763,7 +770,7 @@ export const Files = () => {
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-500'
: 'border-border bg-muted text-muted-foreground'
}`}>
OAuth: {gitHubOAuthConnected() ? 'Connected' : 'Disconnected'}
GitHub Sign-In: {gitHubOAuthConnected() ? 'Connected' : 'Disconnected'}
</span>
<span class={`px-2.5 py-1 rounded-full border ${
gitHubAppStatus().installed
@@ -786,16 +793,26 @@ export const Files = () => {
<IconRefresh class="size-4 mr-2" />
Refresh
</Button>
<Button variant="outline" onClick={() => startGitHubOAuth()}>
<Button variant="outline" onClick={() => startGitHubSignIn()} disabled={!gitHubAppStatus().sign_in_configured}>
<IconBrandGithub class="size-4 mr-2" />
Connect OAuth
Connect GitHub Sign-In
</Button>
<Button onClick={() => handleInstallGitHubApp()} disabled={isGitHubActionLoading()}>
<Button onClick={() => handleInstallGitHubApp()} disabled={isGitHubActionLoading() || !gitHubOAuthConnected()}>
<IconGitFork class="size-4 mr-2" />
Install GitHub App
</Button>
</div>
</div>
<Show when={!gitHubAppStatus().sign_in_configured}>
<p class="text-xs text-amber-500 mt-3">
GitHub sign-in is not available from the unified Trackeep control service.
</p>
</Show>
<Show when={gitHubAppStatus().sign_in_configured && !gitHubOAuthConnected()}>
<p class="text-xs text-muted-foreground mt-3">
Sign in with GitHub first, then install the GitHub App to verify the installation against your account.
</p>
</Show>
</Card>
<Show when={gitHubMessage()}>
@@ -869,11 +886,10 @@ export const Files = () => {
</Button>
</div>
</div>
<Show when={gitHubRepos().length > 0} fallback={
<div class="rounded-xl border border-dashed border-border p-8 text-center">
<p class="text-sm text-muted-foreground">
No repositories available. Connect OAuth or install/configure GitHub App access first.
No repositories available. Connect GitHub sign-in or install/configure GitHub App access first.
</p>
</div>
}>
+209 -56
View File
@@ -3,7 +3,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { GitHubActivity } from '@/components/ui/GitHubActivity';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { startGitHubOAuth } from '@/lib/oauth';
import { startGitHubSignIn } from '@/lib/oauth';
import {
IconBrandGithub,
IconTrendingUp,
@@ -43,6 +43,7 @@ interface GitHubAppInstallation {
interface GitHubAppStatus {
app_slug: string;
install_enabled: boolean;
sign_in_configured: boolean;
credentials_configured: boolean;
installed: boolean;
installation?: GitHubAppInstallation;
@@ -68,6 +69,16 @@ interface GitHubBackupResult {
error?: string;
}
interface GitHubActivityResponse {
contributions: Array<{
date: string;
count: number;
level: number;
}>;
weekly_data: number[];
total_count: number;
}
interface GitHubStats {
totalRepos: number;
totalStars: number;
@@ -107,6 +118,7 @@ export const GitHub = () => {
const [appStatus, setAppStatus] = createSignal<GitHubAppStatus>({
app_slug: '',
install_enabled: false,
sign_in_configured: false,
credentials_configured: false,
installed: false
});
@@ -117,10 +129,38 @@ export const GitHub = () => {
const [isInstallingApp, setIsInstallingApp] = createSignal(false);
const [backupMessage, setBackupMessage] = createSignal('');
const [backupError, setBackupError] = createSignal('');
const [selectedLanguage, setSelectedLanguage] = createSignal<string>('');
const [searchTerm, setSearchTerm] = createSignal<string>('');
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
const selectedCount = () => selectedRepos().length;
const filteredRepos = () => {
let repos = githubStats().repos;
// Filter by language
if (selectedLanguage()) {
repos = repos.filter(repo => repo.language === selectedLanguage());
}
// Filter by search term
if (searchTerm()) {
const term = searchTerm().toLowerCase();
repos = repos.filter(repo =>
repo.name.toLowerCase().includes(term) ||
repo.description?.toLowerCase().includes(term) ||
repo.language?.toLowerCase().includes(term)
);
}
return repos;
};
const uniqueLanguages = () => {
const languages = new Set(githubStats().repos.map(repo => repo.language).filter(Boolean));
return Array.from(languages).sort();
};
const getAuthToken = () => {
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
};
@@ -231,6 +271,7 @@ export const GitHub = () => {
setAppStatus({
app_slug: data.app_slug || '',
install_enabled: Boolean(data.install_enabled),
sign_in_configured: Boolean(data.sign_in_configured),
credentials_configured: Boolean(data.credentials_configured),
installed: Boolean(data.installed),
installation: data.installation
@@ -293,6 +334,39 @@ export const GitHub = () => {
}
};
const fetchGitHubActivity = async () => {
try {
const token = getAuthToken();
if (!token) {
return;
}
const response = await fetch(`${API_BASE_URL}/github/activity`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
return;
}
const data = await response.json() as GitHubActivityResponse;
setWeeklyActivity(data.weekly_data);
// Update GitHubActivity component with real data
updateGitHubActivityComponent(data);
} catch (error) {
console.error('Failed to fetch GitHub activity:', error);
}
};
const updateGitHubActivityComponent = (data: GitHubActivityResponse) => {
// This will be used to update the GitHubActivity component
// For now, we'll store the data and let the component pick it up
const event = new CustomEvent('githubActivityData', { detail: data });
window.dispatchEvent(event);
};
const fetchGitHubStats = async () => {
const loaded = await fetchRepos('/github/repos');
if (!loaded) {
@@ -316,6 +390,7 @@ export const GitHub = () => {
setAppStatus({
app_slug: '',
install_enabled: false,
sign_in_configured: false,
credentials_configured: false,
installed: false
});
@@ -341,17 +416,19 @@ export const GitHub = () => {
}
const userData = await response.json();
const hasOAuthConnection = Boolean(userData?.user?.github_id);
const hasGitHubSignIn = Boolean(userData?.user?.github_id);
setUsername(typeof userData?.user?.username === 'string' ? userData.user.username : '');
setIsConnected(hasOAuthConnection);
setIsConnected(hasGitHubSignIn);
if (hasOAuthConnection) {
if (hasGitHubSignIn) {
await fetchGitHubStats();
await fetchGitHubActivity();
return;
}
if (appInfo?.installed && appInfo.credentials_configured) {
await fetchGitHubAppRepos();
await fetchGitHubActivity();
return;
}
@@ -363,7 +440,7 @@ export const GitHub = () => {
};
const connectGitHub = () => {
startGitHubOAuth();
startGitHubSignIn();
};
const installGitHubApp = async () => {
@@ -427,7 +504,7 @@ export const GitHub = () => {
setBackupMessage('');
setBackupError('');
const source = appStatus().installed ? 'github_app' : 'oauth';
const source = appStatus().installed ? 'github_app' : 'github_user';
const response = await fetch(`${API_BASE_URL}/github/backups`, {
method: 'POST',
headers: {
@@ -484,6 +561,38 @@ export const GitHub = () => {
return 'hsl(var(--primary))';
};
const getLanguageIcon = (language: string) => {
const iconMap: Record<string, string> = {
'Go': '🐹',
'TypeScript': '🔷',
'JavaScript': '🟨',
'CSS': '🎨',
'HTML': '🌐',
'Python': '🐍',
'Dart': '🎯',
'C++': '⚙️',
'MDX': '📝',
'Shell': '🐚',
'Rust': '🦀',
'Java': '☕',
'Ruby': '💎',
'PHP': '🐘',
'Swift': '🍎',
'Kotlin': '🎯',
'Vue': '💚',
'React': '⚛️',
'Angular': '🔺',
'Svelte': '🔥',
'Docker': '🐳',
'YAML': '📄',
'JSON': '📋',
'Markdown': '📝',
'SQL': '🗃️',
'GraphQL': '◈'
};
return iconMap[language] || '📄';
};
return (
<div class="p-6 space-y-6 overflow-x-hidden max-w-full">
{/* Header */}
@@ -511,9 +620,9 @@ export const GitHub = () => {
<Button onClick={() => {
connectGitHub();
haptics.impact();
}}>
}} disabled={!appStatus().sign_in_configured}>
<IconBrandGithub class="size-4 mr-2" />
Connect GitHub
Connect GitHub Sign-In
</Button>
)}
</div>
@@ -528,52 +637,51 @@ export const GitHub = () => {
</div>
<div>
<p class="text-sm font-medium text-foreground">
{isConnected() ? `Connected via OAuth as @${username()}` : `Connected via GitHub App as @${username()}`}
{isConnected() ? `Connected via GitHub App sign-in as @${username()}` : `Connected via GitHub App installation as @${username()}`}
</p>
<p class="text-xs text-muted-foreground">
{isConnected() ? 'Syncing data from GitHub OAuth API' : 'Syncing data from GitHub App installation'}
{isConnected() ? 'Syncing user data with your GitHub App sign-in token' : 'Syncing repositories from the GitHub App installation'}
</p>
</div>
</div>
</Card>
)}
{/* GitHub App Status */}
<Card class="p-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
{appStatus().install_enabled ? (
{/* GitHub App Status - Only show if not installed */}
{!appStatus().installed && appStatus().install_enabled && (
<Card class="p-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
<p class="text-xs text-muted-foreground mt-1">
{appStatus().installed
? `Installed${appStatus().installation?.account_login ? ` for ${appStatus().installation?.account_login}` : ''}`
: 'Not installed yet'}
Not installed yet
</p>
) : (
<p class="text-xs text-muted-foreground mt-1">
GitHub App install is not configured on this server.
</p>
)}
{appStatus().install_enabled && !appStatus().credentials_configured && (
<p class="text-xs text-amber-500 mt-1">
App credentials are missing (`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`).
</p>
)}
{!appStatus().sign_in_configured && (
<p class="text-xs text-amber-500 mt-1">
GitHub sign-in is not available from the unified Trackeep control service.
</p>
)}
{appStatus().install_enabled && !appStatus().credentials_configured && (
<p class="text-xs text-amber-500 mt-1">
Unified GitHub App credentials are not configured on `hq.trackeep.org`.
</p>
)}
</div>
<div class="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
Refresh App Status
</Button>
<Button
size="sm"
disabled={!appStatus().install_enabled || isInstallingApp() || !isConnected()}
onClick={() => installGitHubApp()}
>
{isInstallingApp() ? 'Opening...' : 'Install GitHub App'}
</Button>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
Refresh App Status
</Button>
<Button
size="sm"
disabled={!appStatus().install_enabled || isInstallingApp()}
onClick={() => installGitHubApp()}
>
{isInstallingApp() ? 'Opening...' : (appStatus().installed ? 'Reinstall GitHub App' : 'Install GitHub App')}
</Button>
</div>
</div>
</Card>
</Card>
)}
{/* Stats Overview - 2-column layout with larger left column */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -671,8 +779,9 @@ export const GitHub = () => {
) : (
<div class="space-y-3">
{githubStats().languages.map((language) => (
<div class="flex items-center justify-between">
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer">
<div class="flex items-center gap-3">
<span class="text-lg">{getLanguageIcon(language.name)}</span>
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style={`background-color: ${language.color}`}
@@ -790,6 +899,45 @@ export const GitHub = () => {
)}
</div>
{/* Filters */}
{githubStats().repos.length > 0 && (
<div class="flex flex-col md:flex-row gap-3 mb-4 p-3 bg-muted/30 rounded-lg">
<div class="flex-1">
<input
type="text"
placeholder="Search repositories..."
class="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
/>
</div>
<div class="flex gap-2">
<select
class="px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
value={selectedLanguage()}
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
>
<option value="">All Languages</option>
{uniqueLanguages().map(lang => (
<option value={lang}>{lang}</option>
))}
</select>
{(selectedLanguage() || searchTerm()) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedLanguage('');
setSearchTerm('');
}}
>
Clear Filters
</Button>
)}
</div>
</div>
)}
{backupMessage() && (
<p class="text-sm text-emerald-500 mb-3">{backupMessage()}</p>
)}
@@ -819,23 +967,25 @@ export const GitHub = () => {
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
) : (
<div class="space-y-4">
{githubStats().repos.map((repo) => (
<div class="border border-border rounded-lg p-4">
{filteredRepos().map((repo) => (
<div class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-colors cursor-pointer group">
<div class="flex items-start gap-3">
<input
type="checkbox"
class="mt-1 h-4 w-4 rounded border-border accent-primary"
checked={selectedRepos().includes(repo.full_name)}
onChange={() => toggleRepoSelection(repo.full_name)}
onClick={(e) => e.stopPropagation()}
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
<h4 class="text-lg font-medium text-foreground group-hover:text-primary transition-colors">{repo.name}</h4>
{repo.language && (
<span
class="text-xs px-2 py-1 rounded-full"
class="text-xs px-2 py-1 rounded-full flex items-center gap-1"
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
>
<span>{getLanguageIcon(repo.language)}</span>
{repo.language}
</span>
)}
@@ -857,14 +1007,17 @@ export const GitHub = () => {
<span>Updated {formatDate(repo.updated_at)}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
aria-label={`Open ${repo.full_name}`}
>
<IconExternalLink class="size-4" />
</Button>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
aria-label={`Open ${repo.full_name}`}
class="opacity-0 group-hover:opacity-100 transition-opacity"
>
<IconExternalLink class="size-4" />
</Button>
</div>
</div>
</div>
))}
+40 -9
View File
@@ -2,7 +2,7 @@ import { createSignal, onMount } from 'solid-js';
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
import { isEnvDemoMode } from '@/lib/demo-mode';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { startGitHubOAuth } from '@/lib/oauth';
import { startGitHubSignIn } from '@/lib/oauth';
import { useNavigate } from '@solidjs/router';
import { IconBrandGithub } from '@tabler/icons-solidjs';
@@ -15,6 +15,14 @@ interface LoginFormData {
fullName: string;
}
function getSafeNextPath(): string {
const next = new URLSearchParams(window.location.search).get('next') || '';
if (!next.startsWith('/') || next.startsWith('//')) {
return '/app';
}
return next;
}
export const Login = () => {
const { login, register } = useAuth();
const navigate = useNavigate();
@@ -26,6 +34,7 @@ export const Login = () => {
fullName: '',
});
const [error, setError] = createSignal('');
const [setupError, setSetupError] = createSignal('');
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
const [registrationDisabled, setRegistrationDisabled] = createSignal(false);
const [loading, setLoading] = createSignal(false);
@@ -57,6 +66,7 @@ export const Login = () => {
});
if (response.ok) {
setSetupError('');
const data = await response.json();
if (data.hasUsers) {
// Users exist - disable registration
@@ -84,15 +94,27 @@ export const Login = () => {
});
}
} else {
// Default to login mode if backend returns error
let backendMessage = 'Unable to determine whether this Trackeep instance has been initialized. Check the backend logs and database schema.';
try {
const data = await response.json();
if (typeof data?.error === 'string' && data.error.trim() !== '') {
backendMessage = `${backendMessage} Backend error: ${data.error}`;
}
} catch {
// Ignore JSON parsing errors and fall back to the generic message.
}
setSetupError(backendMessage);
setIsLogin(true);
setRegistrationDisabled(true);
setRegistrationDisabled(false);
setNoAccountsExist(false);
}
} catch (err) {
console.warn('Failed to check if users exist:', err);
// Default to login mode if backend is unavailable
setSetupError('Unable to reach the backend to determine whether any accounts exist yet.');
setIsLogin(true);
setRegistrationDisabled(true);
setRegistrationDisabled(false);
setNoAccountsExist(false);
}
});
@@ -117,8 +139,7 @@ export const Login = () => {
};
await register(registerPayload);
}
// Navigate to app after successful login/registration
navigate('/app');
navigate(getSafeNextPath(), { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
@@ -189,6 +210,16 @@ export const Login = () => {
) : (
<>
{/* Registration disabled message */}
{setupError() && (
<div class="mb-6 bg-amber-500/10 border border-amber-500/50 text-amber-300 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 bg-amber-400 rounded-full"></span>
<span class="font-medium">Setup Check Failed</span>
</div>
<p class="text-xs">{setupError()}</p>
</div>
)}
{registrationDisabled() && (
<div class="mb-6 bg-blue-500/10 border border-blue-500/50 text-blue-400 px-4 py-3 rounded">
<div class="flex items-center gap-2 mb-1">
@@ -298,11 +329,11 @@ export const Login = () => {
<button
type="button"
onClick={startGitHubOAuth}
onClick={startGitHubSignIn}
class="w-full flex items-center justify-center gap-2 bg-[#0f0f10] text-[#fafafa] py-2 px-4 rounded-md border border-[#262626] hover:border-[#39b9ff]/50 hover:bg-[#18181b] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] transition-colors"
>
<IconBrandGithub class="size-4" />
Continue with GitHub
Continue with GitHub App
</button>
</form>
+185 -39
View File
@@ -1,36 +1,61 @@
/* Modern Messages Styling - Matching Chat.tsx Design System */
/* Main container with modern backdrop */
/* Full-screen shell aligned with the AI chat layout */
.messages-shell {
height: 100%;
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
display: flex;
flex: 1;
min-height: 0;
background:
radial-gradient(circle at top left, hsl(var(--primary) / 0.08), transparent 28rem),
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 100%);
}
/* Screen management */
.messages-shell-list .messages-main {
display: none;
.messages-alert-wrap {
padding: 1rem 1rem 0;
background: hsl(var(--background));
}
.messages-shell-conversation .messages-sidebar {
display: none;
.messages-spotlight-alert {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border-radius: 1rem;
border: 1px solid hsl(var(--border) / 0.8);
padding: 0.9rem 1rem;
box-shadow: 0 18px 50px -32px hsl(0 0% 0% / 0.85);
}
.messages-spotlight-copy {
min-width: 0;
}
.messages-spotlight-error {
background: linear-gradient(135deg, hsl(var(--destructive) / 0.14), hsl(var(--background)));
border-color: hsl(var(--destructive) / 0.3);
}
.messages-spotlight-warning {
background: linear-gradient(135deg, hsl(38 92% 50% / 0.16), hsl(var(--background)));
border-color: hsl(38 92% 50% / 0.25);
}
.messages-spotlight-info {
background: linear-gradient(135deg, hsl(var(--primary) / 0.16), hsl(var(--background)));
border-color: hsl(var(--primary) / 0.25);
}
/* Modern sidebar styling */
.messages-sidebar {
border-inline: 1px solid hsl(var(--border));
width: 20rem;
max-width: 22rem;
border-right: 1px solid hsl(var(--border));
background: hsl(var(--card));
display: flex;
flex-direction: column;
min-height: 0;
width: min(100%, 980px);
margin: 0 auto;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.messages-sidebar-header {
padding: 1.5rem;
padding: 1rem 1rem 0.9rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--card) / 0.95);
backdrop-filter: blur(10px);
@@ -85,34 +110,43 @@
/* Modern conversation items */
.conversation-item {
border: 1px solid transparent;
border-radius: 0.75rem;
padding: 1rem;
border: 1px solid hsl(var(--border) / 0.55);
border-radius: 0.9rem;
padding: 0.95rem 1rem;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.conversation-item:hover {
background: hsl(var(--muted) / 0.6);
background: hsl(var(--muted) / 0.45);
border-color: hsl(var(--primary) / 0.18);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.08);
box-shadow: 0 16px 30px -26px hsl(0 0% 0% / 0.85);
}
.conversation-item-active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary) / 0.2);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.08);
background: linear-gradient(135deg, hsl(var(--primary) / 0.14), hsl(var(--background)));
border-color: hsl(var(--primary) / 0.3);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.08);
}
.conversation-item-main {
flex: 1;
min-width: 0;
}
.conversation-item-heading,
.conversation-item-footer {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.conversation-item-name {
font-size: 0.875rem;
font-weight: 600;
@@ -129,6 +163,7 @@
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.25rem;
flex: 1;
}
.conversation-item-unread {
@@ -146,24 +181,78 @@
box-shadow: 0 2px 4px hsl(var(--primary) / 0.2);
}
.conversation-item-avatar {
width: 2.75rem;
height: 2.75rem;
border-radius: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
flex-shrink: 0;
border: 1px solid hsl(var(--border) / 0.35);
color: hsl(var(--foreground));
}
.conversation-item-avatar-dm {
background: linear-gradient(135deg, hsl(var(--primary) / 0.18), hsl(var(--primary) / 0.08));
}
.conversation-item-avatar-group {
background: linear-gradient(135deg, hsl(216 92% 60% / 0.22), hsl(192 92% 55% / 0.12));
}
.conversation-item-avatar-team {
background: linear-gradient(135deg, hsl(158 75% 42% / 0.22), hsl(143 70% 52% / 0.12));
}
.conversation-item-avatar-global {
background: linear-gradient(135deg, hsl(38 92% 56% / 0.24), hsl(16 92% 60% / 0.12));
}
.conversation-item-avatar-self {
background: linear-gradient(135deg, hsl(280 70% 60% / 0.2), hsl(324 70% 60% / 0.12));
}
.conversation-item-avatar-vault {
background: linear-gradient(135deg, hsl(0 0% 100% / 0.08), hsl(0 0% 100% / 0.02));
}
.conversation-item-avatar-default {
background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--muted) / 0.7));
}
.conversation-item-time {
font-size: 0.7rem;
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.conversation-item-badge {
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.62rem;
font-weight: 600;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
flex-shrink: 0;
}
/* Modern main conversation area */
.messages-main {
width: min(100%, 1180px);
margin: 0 auto;
flex: 1;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
border-inline: 1px solid hsl(var(--border));
background: hsl(var(--card));
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
backdrop-filter: blur(10px);
background: hsl(var(--background));
}
.messages-main-header {
border-bottom: 1px solid hsl(var(--border));
padding: 1.5rem;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
@@ -214,15 +303,51 @@
.messages-main-empty {
display: grid;
place-items: center;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
padding: 3rem;
}
.messages-main-empty-card {
width: min(28rem, 100%);
border: 1px solid hsl(var(--border) / 0.7);
border-radius: 1.5rem;
padding: 2rem;
text-align: center;
background:
radial-gradient(circle at top, hsl(var(--primary) / 0.12), transparent 65%),
hsl(var(--card) / 0.96);
box-shadow: 0 28px 80px -44px hsl(0 0% 0% / 0.7);
}
.messages-main-empty-orb {
width: 3.5rem;
height: 3.5rem;
border-radius: 1.2rem;
margin: 0 auto 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, hsl(var(--primary) / 0.18), hsl(var(--primary) / 0.06));
border: 1px solid hsl(var(--primary) / 0.18);
}
.messages-main-empty-title {
font-size: 1.05rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.messages-main-empty-copy {
margin: 0.65rem auto 1.25rem;
max-width: 22rem;
color: hsl(var(--muted-foreground));
font-size: 0.9rem;
line-height: 1.55;
}
/* Modern call and transcript strips */
.messages-call-strip,
.messages-transcript-preview {
padding: 1rem 1.5rem;
padding: 0.85rem 1.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
@@ -239,7 +364,8 @@
overflow-y: auto;
display: grid;
gap: 1.5rem;
background: hsl(var(--background));
background:
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.14) 100%);
}
.message-row {
@@ -478,6 +604,26 @@
backdrop-filter: blur(10px);
}
@media (max-width: 767px) {
.messages-shell {
display: block;
}
.messages-sidebar,
.messages-main {
width: 100%;
max-width: none;
}
.messages-shell-list .messages-main {
display: none;
}
.messages-shell-conversation .messages-sidebar {
display: none;
}
}
.messages-composer-drag {
background: hsl(var(--primary) / 0.08);
border-color: hsl(var(--primary) / 0.2);
+255 -12
View File
@@ -157,6 +157,19 @@ const REFERENCE_TYPE_OPTIONS = [
type TriStateFilter = 'any' | 'yes' | 'no';
type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error';
type SpotlightAlertTone = 'error' | 'warning' | 'info';
interface SpotlightAlert {
tone: SpotlightAlertTone;
title: string;
message?: string;
}
interface MessagingAlertContent {
tone: SpotlightAlertTone;
title: string;
message: string;
}
export const Messages = () => {
const haptics = useHaptics();
@@ -233,6 +246,7 @@ export const Messages = () => {
const [aiShareLoadingSessions, setAiShareLoadingSessions] = createSignal(false);
const [aiShareLoadingMessages, setAiShareLoadingMessages] = createSignal(false);
const [composerAiReferences, setComposerAiReferences] = createSignal<ComposerAIReference[]>([]);
const [spotlightAlert, setSpotlightAlert] = createSignal<SpotlightAlert | null>(null);
const getCurrentUserId = () => {
const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
@@ -271,6 +285,7 @@ export const Messages = () => {
let composerTextareaRef: HTMLTextAreaElement | null = null;
let hiddenFileInputRef: HTMLInputElement | null = null;
const sensitiveRevealTimers = new Map<number, number>();
let spotlightAlertTimer: number | null = null;
const sortedConversations = () =>
[...conversations()].sort((a, b) => {
@@ -299,6 +314,25 @@ export const Messages = () => {
setActiveScreen('list');
};
const showSpotlightAlert = (tone: SpotlightAlertTone, title: string, message?: string) => {
setSpotlightAlert({ tone, title, message });
if (spotlightAlertTimer) {
window.clearTimeout(spotlightAlertTimer);
}
spotlightAlertTimer = window.setTimeout(() => {
setSpotlightAlert(null);
spotlightAlertTimer = null;
}, 7000);
};
const dismissSpotlightAlert = () => {
if (spotlightAlertTimer) {
window.clearTimeout(spotlightAlertTimer);
spotlightAlertTimer = null;
}
setSpotlightAlert(null);
};
const shouldUseSensitiveReveal = () => {
const type = activeConversation()?.conversation.type;
return type === 'dm' || type === 'self';
@@ -307,6 +341,104 @@ export const Messages = () => {
const conversationNameById = (id: number) =>
conversations().find((item) => item.conversation.id === id)?.conversation.name || 'Conversation';
const formatConversationPreview = (item: ConversationListItem) => {
const body = item.last_message?.body?.trim();
if (body) return body;
if (item.conversation.topic?.trim()) return item.conversation.topic.trim();
switch (item.conversation.type) {
case 'dm':
return 'Direct message';
case 'group':
return 'Group chat';
case 'team':
return 'Team channel';
case 'global':
return 'Workspace channel';
case 'self':
return 'Notes to self';
case 'password_vault':
return 'Password vault';
default:
return 'Conversation';
}
};
const formatConversationTime = (item: ConversationListItem) => {
const raw = item.conversation.last_message_at || item.last_message?.created_at || item.conversation.updated_at;
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return '';
const now = new Date();
const sameDay = parsed.toDateString() === now.toDateString();
if (sameDay) {
return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (parsed.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
}
return parsed.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const getConversationInitials = (item: ConversationListItem) => {
const label = (item.conversation.name || item.conversation.type || 'C').trim();
if (label.startsWith('#')) {
return '#';
}
const parts = label.split(/\s+/).filter(Boolean).slice(0, 2);
if (parts.length === 0) return 'C';
return parts.map((part) => part.charAt(0).toUpperCase()).join('');
};
const getConversationAvatarClass = (item: ConversationListItem) => {
switch (item.conversation.type) {
case 'dm':
return 'conversation-item-avatar-dm';
case 'group':
return 'conversation-item-avatar-group';
case 'team':
return 'conversation-item-avatar-team';
case 'global':
return 'conversation-item-avatar-global';
case 'self':
return 'conversation-item-avatar-self';
case 'password_vault':
return 'conversation-item-avatar-vault';
default:
return 'conversation-item-avatar-default';
}
};
const formatMessagingAlert = (error: unknown, fallbackTitle: string): MessagingAlertContent => {
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
if (/Failed to initialize messaging defaults/i.test(rawMessage)) {
return {
tone: 'error',
title: 'Messaging is not ready yet',
message: 'The backend could not provision messaging defaults. Reload after the migration fix is applied.',
};
}
if (/User not authenticated/i.test(rawMessage)) {
return {
tone: 'warning',
title: 'Session expired',
message: 'Sign in again to keep using messages.',
};
}
return {
tone: 'error',
title: fallbackTitle,
message: rawMessage,
};
};
const normalizeReactionKey = (value: string): ReactionKey => {
const raw = value.trim().toLowerCase();
if (!raw) return 'thumb_up';
@@ -568,6 +700,7 @@ export const Messages = () => {
const requestNotificationPermission = async () => {
if (!('Notification' in window)) {
toast.warning('Browser notifications unavailable', 'This browser does not support notifications.');
showSpotlightAlert('warning', 'Browser notifications unavailable', 'This browser does not support notifications.');
return;
}
if (Notification.permission === 'granted') {
@@ -579,6 +712,15 @@ export const Messages = () => {
const enabled = permission === 'granted';
setBrowserNotificationsEnabled(enabled);
localStorage.setItem('messages_browser_notifications', enabled ? 'true' : 'false');
if (!enabled) {
showSpotlightAlert(
'warning',
permission === 'denied' ? 'Notifications blocked' : 'Permission dismissed',
permission === 'denied'
? 'Browser notifications are blocked. Enable them in your browser settings if you want message alerts.'
: 'Notifications stay off until you allow them.'
);
}
};
const maybeNotify = (title: string, body: string) => {
@@ -1049,6 +1191,7 @@ export const Messages = () => {
toast.info('Voice call', 'Call connected.');
} catch (error) {
setCallStatus('error');
showSpotlightAlert('error', 'Failed to answer call', error instanceof Error ? error.message : 'Unknown error');
toast.error('Failed to answer call', error instanceof Error ? error.message : 'Unknown error');
}
};
@@ -1125,6 +1268,7 @@ export const Messages = () => {
.map((member) => member.user_id)
.filter((id) => id !== currentUserId());
if (peers.length === 0) {
showSpotlightAlert('warning', 'No participants', 'Add at least one other user to call.');
toast.warning('No participants', 'Add at least one other user to call.');
return;
}
@@ -1153,6 +1297,7 @@ export const Messages = () => {
} catch (error) {
setCallStatus('error');
cleanupCallResources();
showSpotlightAlert('error', 'Failed to start call', error instanceof Error ? error.message : 'Unknown error');
toast.error('Failed to start call', error instanceof Error ? error.message : 'Unknown error');
}
};
@@ -1181,9 +1326,16 @@ export const Messages = () => {
if (nextConversations.length === 0) {
setSelectedConversationId(null);
setActiveScreen('list');
} else if (!selectedID) {
const firstConversationID = nextConversations[0].conversation.id;
setSelectedConversationId(firstConversationID);
if (window.innerWidth >= 768) {
setActiveScreen('conversation');
}
}
} catch (error) {
toast.error('Failed to load conversations', error instanceof Error ? error.message : 'Unknown error');
const alert = formatMessagingAlert(error, 'Failed to load conversations');
showSpotlightAlert(alert.tone, alert.title, alert.message);
} finally {
setLoadingConversations(false);
}
@@ -1195,7 +1347,8 @@ export const Messages = () => {
const data = await messagesApi.getMessages(conversationId, undefined, 50);
setMessages(data.messages || []);
} catch (error) {
toast.error('Failed to load messages', error instanceof Error ? error.message : 'Unknown error');
const alert = formatMessagingAlert(error, 'Failed to load messages');
showSpotlightAlert(alert.tone, alert.title, alert.message);
} finally {
setLoadingMessages(false);
}
@@ -1906,6 +2059,10 @@ export const Messages = () => {
window.clearTimeout(typingStopTimer);
typingStopTimer = null;
}
if (spotlightAlertTimer) {
window.clearTimeout(spotlightAlertTimer);
spotlightAlertTimer = null;
}
for (const timer of Array.from(sensitiveRevealTimers.values())) {
window.clearTimeout(timer);
}
@@ -1948,9 +2105,73 @@ export const Messages = () => {
onCleanup(() => clearInterval(wsWatcher));
return (
<div class="mt-4 pb-32 max-w-7xl mx-auto">
<div class="bg-background rounded-lg border shadow-sm">
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
<div class="h-full w-full flex flex-col bg-background">
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
<div class="flex items-center justify-between gap-4 px-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<Button
variant="ghost"
size="sm"
onClick={() => setActiveScreen((screen) => (screen === 'conversation' ? 'list' : selectedConversationId() ? 'conversation' : 'list'))}
class="md:hidden"
>
<IconMessageCircle class="size-4" />
</Button>
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center shrink-0">
<IconMessageCircle class="size-5 text-primary" />
</div>
<div class="min-w-0">
<h1 class="font-semibold text-lg text-foreground">Messages</h1>
<p class="text-sm text-muted-foreground truncate">Direct messages, channels, calls, and AI references</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setSearchOpen(true)}>
<IconSearch class="size-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (browserNotificationsEnabled()) {
setBrowserNotificationsEnabled(false);
localStorage.setItem('messages_browser_notifications', 'false');
} else {
requestNotificationPermission();
}
}}
>
<Show when={browserNotificationsEnabled()} fallback={<IconBellOff class="size-4" />}>
<IconBell class="size-4" />
</Show>
</Button>
<Button size="sm" onClick={() => setShowCreateConversation(true)} class="hidden sm:inline-flex">
<IconPlus class="size-4 mr-1" />
New Chat
</Button>
</div>
</div>
</header>
<Show when={spotlightAlert()}>
{(alert) => (
<div class="messages-alert-wrap">
<div class={`messages-spotlight-alert messages-spotlight-${alert().tone}`}>
<div class="messages-spotlight-copy">
<p class="font-medium text-foreground">{alert().title}</p>
<Show when={alert().message}>
<p class="text-sm text-muted-foreground mt-1">{alert().message}</p>
</Show>
</div>
<Button size="sm" variant="ghost" onClick={dismissSpotlightAlert}>
<IconX class="size-4" />
</Button>
</div>
</div>
)}
</Show>
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
<aside class="messages-sidebar">
<div class="messages-sidebar-header">
<div class="messages-title-row">
@@ -2009,11 +2230,22 @@ export const Messages = () => {
}`}
onClick={() => openConversation(item.conversation.id)}
>
<div class={`conversation-item-avatar ${getConversationAvatarClass(item)}`}>
<span>{getConversationInitials(item)}</span>
</div>
<div class="conversation-item-main">
<p class="conversation-item-name">{item.conversation.name}</p>
<p class="conversation-item-preview">
{item.last_message?.body || item.conversation.topic || item.conversation.type}
</p>
<div class="conversation-item-heading">
<p class="conversation-item-name">{item.conversation.name}</p>
<Show when={formatConversationTime(item)}>
<span class="conversation-item-time">{formatConversationTime(item)}</span>
</Show>
</div>
<div class="conversation-item-footer">
<p class="conversation-item-preview">{formatConversationPreview(item)}</p>
<Show when={item.conversation.is_default}>
<span class="conversation-item-badge">Default</span>
</Show>
</div>
</div>
<Show when={item.unread_count > 0}>
<span class="conversation-item-unread">{item.unread_count}</span>
@@ -2075,7 +2307,19 @@ export const Messages = () => {
when={selectedConversationId()}
fallback={
<div class="messages-main-empty">
Select a conversation from the list.
<div class="messages-main-empty-card">
<div class="messages-main-empty-orb">
<IconMessageCircle class="size-6 text-primary" />
</div>
<h3 class="messages-main-empty-title">Pick a conversation</h3>
<p class="messages-main-empty-copy">
Jump into your self chat, browse workspace channels, or start a new direct message.
</p>
<Button onClick={() => setShowCreateConversation(true)}>
<IconPlus class="size-4 mr-1" />
New Chat
</Button>
</div>
</div>
}
>
@@ -2574,6 +2818,7 @@ export const Messages = () => {
</div>
</Show>
</section>
</div>
<Show when={showCreateConversation()}>
<div
@@ -2989,8 +3234,6 @@ export const Messages = () => {
</div>
</Show>
</div>
</div>
</div>
);
};
+324 -393
View File
@@ -1,8 +1,10 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
import { createSignal, onMount, For, Show } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
import { NoteModal } from '@/components/ui/NoteModal';
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
import { getMockNotes } from '@/lib/mockData';
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
@@ -30,46 +32,69 @@ interface Note {
isHtml?: boolean;
}
const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
const html = content
.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-1">$1<\/h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1<\/h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1<\/h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1<\/h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
.replace(/\n\n+/g, '<\/p><p class="mb-2">');
const parts = html.split('<\/p><p class="mb-2">');
const limited = parts.slice(0, maxBlocks).join('<\/p><p class="mb-2">');
return limited;
const normalizeNoteId = (value: unknown, fallback: number): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string') {
const digits = value.match(/\d+/)?.[0];
if (digits) {
const parsed = Number(digits);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return fallback;
};
const renderPlainTextPreviewHtml = (content: string): string => {
return content
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
.split('\n')
.slice(0, 6)
.map((line) => (line ? line : '<br \/>'))
.join('\n');
const normalizeNoteDate = (value: unknown, fallback: Date = new Date()): string => {
if (typeof value === 'string') {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
const relativeMatch = value.trim().match(/^(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago$/i);
if (relativeMatch) {
const amount = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
const relativeDate = new Date();
const operations: Record<string, (date: Date, quantity: number) => void> = {
minute: (date, quantity) => date.setMinutes(date.getMinutes() - quantity),
hour: (date, quantity) => date.setHours(date.getHours() - quantity),
day: (date, quantity) => date.setDate(date.getDate() - quantity),
week: (date, quantity) => date.setDate(date.getDate() - quantity * 7),
month: (date, quantity) => date.setMonth(date.getMonth() - quantity),
year: (date, quantity) => date.setFullYear(date.getFullYear() - quantity),
};
operations[unit]?.(relativeDate, amount);
return relativeDate.toISOString();
}
}
return fallback.toISOString();
};
const getNoteKind = (note: Note): 'html' | 'markdown' | 'plain' => {
if (note.isHtml) return 'html';
if (note.isMarkdown) return 'markdown';
return 'plain';
};
const formatDisplayDate = (value: string): string => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'Unknown';
}
return parsed.toLocaleDateString();
};
export const Notes = () => {
const haptics = useHaptics();
const [notes, setNotes] = createSignal<Note[]>([]);
const [isLoading, setIsLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedTags, setSelectedTags] = createSignal<string[]>([]);
const [showAddModal, setShowAddModal] = createSignal(false);
@@ -83,6 +108,7 @@ export const Notes = () => {
onMount(async () => {
try {
let notesData: any[] = [];
setLoadError('');
// Check if we should use demo mode or real API
if (isDemoMode() && !shouldUseRealBackend()) {
@@ -113,11 +139,11 @@ export const Notes = () => {
: [];
const content = note.content || '';
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
const updatedAt = note.updated_at || note.updatedAt || createdAt;
const createdAt = normalizeNoteDate(note.created_at || note.createdAt || note.createdAtLabel, new Date());
const updatedAt = normalizeNoteDate(note.updated_at || note.updatedAt || createdAt, new Date(createdAt));
return {
id: Number(note.id || index + 1),
id: normalizeNoteId(note.id, index + 1),
title: note.title || 'Untitled note',
content,
createdAt,
@@ -133,7 +159,7 @@ export const Notes = () => {
url: att.url,
}))
: [],
isMarkdown: content.includes('#') || content.includes('*'),
isMarkdown: Boolean(note.isMarkdown) || content.includes('#') || content.includes('*') || content.includes('```'),
isHtml: content.includes('<') && content.includes('>'),
};
});
@@ -141,44 +167,9 @@ export const Notes = () => {
setNotes(adaptedNotes);
} catch (error) {
console.error('Failed to load notes:', error);
// Fallback to demo data if API fails
if (!isDemoMode()) {
console.log('[Notes] API failed, falling back to demo data');
const mockNotesData = getMockNotes();
const adaptedNotes: Note[] = mockNotesData.map((note: any, index) => {
const tags = Array.isArray(note.tags)
? note.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
: [];
const content = note.content || '';
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
const updatedAt = note.updated_at || note.updatedAt || createdAt;
return {
id: Number(note.id || index + 1),
title: note.title || 'Untitled note',
content,
createdAt,
updatedAt,
tags,
pinned: Boolean(note.pinned ?? note.is_pinned ?? tags.includes('pinned')),
attachments: Array.isArray(note.attachments)
? note.attachments.map((att: any, attachmentIndex: number) => ({
id: String(att.id || `att_${attachmentIndex}`),
name: att.name || 'attachment',
type: att.type || 'file',
size: att.size || '',
url: att.url,
}))
: [],
isMarkdown: content.includes('#') || content.includes('*'),
isHtml: content.includes('<') && content.includes('>'),
};
});
setNotes(adaptedNotes);
} else {
setNotes([]);
}
setNotes([]);
const message = error instanceof Error ? error.message : 'Failed to load notes.';
setLoadError(message);
} finally {
setIsLoading(false);
}
@@ -225,14 +216,17 @@ export const Notes = () => {
try {
if (isDemoMode() && !shouldUseRealBackend()) {
// Demo mode: Add note locally
const now = new Date();
const note: Note = {
id: Date.now(),
title: noteData.title,
content: noteData.content,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
tags: noteData.tags,
pinned: false
pinned: false,
isMarkdown: Boolean(noteData.content?.includes('```') || noteData.content?.includes('#')),
isHtml: Boolean(noteData.content?.includes('<') && noteData.content?.includes('>')),
};
setNotes(prev => [note, ...prev]);
@@ -255,16 +249,16 @@ export const Notes = () => {
const newNote = await response.json();
const adaptedNote: Note = {
id: newNote.id,
id: normalizeNoteId(newNote.id, Date.now()),
title: newNote.title,
content: newNote.content,
createdAt: newNote.created_at || newNote.createdAt,
updatedAt: newNote.updated_at || newNote.updatedAt,
content: newNote.content || '',
createdAt: normalizeNoteDate(newNote.created_at || newNote.createdAt || new Date().toISOString()),
updatedAt: normalizeNoteDate(newNote.updated_at || newNote.updatedAt || newNote.created_at || new Date().toISOString()),
tags: Array.isArray(newNote.tags) ? newNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : [],
pinned: Boolean(newNote.pinned ?? newNote.is_pinned),
attachments: [],
isMarkdown: newNote.content.includes('#') || newNote.content.includes('*'),
isHtml: newNote.content.includes('<') && newNote.content.includes('>'),
isMarkdown: Boolean(newNote.content?.includes('#') || newNote.content?.includes('*') || newNote.content?.includes('```')),
isHtml: Boolean(newNote.content?.includes('<') && newNote.content?.includes('>')),
};
setNotes(prev => [adaptedNote, ...prev]);
@@ -431,7 +425,6 @@ export const Notes = () => {
URL.revokeObjectURL(url);
};
// Add this function to handle checkbox changes
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
setNotes(prev => prev.map(note => {
if (note.id === noteId) {
@@ -443,11 +436,12 @@ export const Notes = () => {
const checkedMatch = line.match(/^- \[x\] (.*)$/);
if (uncheckedMatch || checkedMatch) {
if (checkboxCount === checkboxIndex) {
const currentCheckboxIndex = checkboxCount;
checkboxCount++;
if (currentCheckboxIndex === checkboxIndex) {
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
}
checkboxCount++;
}
return line;
});
@@ -460,6 +454,35 @@ export const Notes = () => {
}
return note;
}));
setViewingNote(prev => {
if (!prev || prev.id !== noteId) {
return prev;
}
const lines = prev.content.split('\n');
let checkboxCount = 0;
const updatedLines = lines.map(line => {
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
const checkedMatch = line.match(/^- \[x\] (.*)$/);
if (uncheckedMatch || checkedMatch) {
const currentCheckboxIndex = checkboxCount;
checkboxCount++;
if (currentCheckboxIndex === checkboxIndex) {
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
}
}
return line;
});
return {
...prev,
content: updatedLines.join('\n'),
updatedAt: new Date().toISOString(),
};
});
};
// Handler for updating note content from ViewNoteModal
@@ -469,327 +492,235 @@ export const Notes = () => {
? { ...note, content, updatedAt: new Date().toISOString() }
: note
));
setViewingNote(prev =>
prev && prev.id === noteId
? { ...prev, content, updatedAt: new Date().toISOString() }
: prev
);
};
// Make the function available globally for checkbox onchange handlers
createEffect(() => {
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
const noteElement = checkbox.closest('[data-note-id]');
if (noteElement) {
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
}
};
});
return (
<div class="fixed inset-0 bg-background flex flex-col">
<style>
{`
.note-checkbox {
width: 16px !important;
height: 16px !important;
cursor: pointer !important;
accent-color: #3b82f6 !important;
border: 2px solid #4b5563 !important;
border-radius: 3px !important;
transition: all 0.2s ease !important;
}
.note-checkbox:hover {
border-color: #3b82f6 !important;
transform: scale(1.1) !important;
}
.note-checkbox:checked {
background-color: #3b82f6 !important;
border-color: #3b82f6 !important;
}
.note-checkbox:focus {
outline: 2px solid #3b82f6 !important;
outline-offset: 2px !important;
}
`}
</style>
{/* Header - Signal/Discord style */}
<div class="bg-[#2c2c2e] border-b border-[#404040] px-4 py-3 flex-shrink-0">
<div class="flex justify-between items-center">
<div>
<h1 class="text-xl font-semibold text-white">Notes</h1>
<p class="text-sm text-[#b9b9b9]">Your personal notes</p>
</div>
<Button
onClick={() => {
setShowAddModal(true);
haptics.impact();
}}
class="bg-[#5865f2] hover:bg-[#4752c4] text-white border-0"
>
Add Note
</Button>
<div class="p-6 space-y-6">
<div class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-bold text-foreground">Notes</h1>
<p class="text-muted-foreground mt-1">Your personal notes</p>
</div>
<Button
onClick={() => {
setShowAddModal(true);
haptics.impact();
}}
>
Add Note
</Button>
</div>
{/* Main Content Area */}
<div class="flex-1 flex overflow-hidden">
{/* Sidebar - Discord style */}
<div class="w-64 bg-[#202225] border-r border-[#404040] flex-shrink-0 overflow-y-auto">
<div class="p-4">
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Search & Filter</h3>
<SearchTagFilterBar
searchPlaceholder="Search notes..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<div class="mt-6">
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Quick Stats</h3>
<div class="space-y-2">
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Total Notes</span>
<span class="text-sm font-medium text-white">{filteredNotes().length}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Pinned</span>
<span class="text-sm font-medium text-white">{filteredNotes().filter(n => n.pinned).length}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-[#b9b9b9]">Tags</span>
<span class="text-sm font-medium text-white">{allTags().length}</span>
</div>
</div>
</div>
</div>
<SearchTagFilterBar
searchPlaceholder="Search notes..."
searchValue={searchTerm()}
onSearchChange={(value) => setSearchTerm(value)}
tagOptions={allTags()}
selectedTag={selectedTags()[0] || ''}
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
onReset={() => {
setSearchTerm('');
setSelectedTags([]);
}}
/>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Total Notes</p>
<p class="text-xl font-semibold text-foreground">{filteredNotes().length}</p>
</Card>
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Pinned</p>
<p class="text-xl font-semibold text-foreground">{filteredNotes().filter((note) => note.pinned).length}</p>
</Card>
<Card class="p-4">
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Tags</p>
<p class="text-xl font-semibold text-foreground">{allTags().length}</p>
</Card>
</div>
<Show when={loadError()}>
<Card class="border-destructive/30 bg-destructive/5 p-4">
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
<p class="mt-1 text-sm text-muted-foreground">{loadError()}</p>
</Card>
</Show>
<Show when={copiedContent()}>
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
Content copied!
</div>
</Show>
{/* Notes List - Signal style chat */}
<div class="flex-1 bg-[#36393f] overflow-y-auto">
<div class="p-4 space-y-2">
<Show when={copiedContent()}>
<div class="bg-green-500/20 border border-green-500/50 text-green-400 px-3 py-2 rounded-lg text-sm mb-4">
Content copied!
<Show when={isLoading()}>
<div class="space-y-4">
{[...Array(3)].map(() => (
<Card class="p-6">
<div class="animate-pulse">
<div class="h-6 bg-muted rounded mb-2"></div>
<div class="h-4 bg-muted rounded w-3/4"></div>
</div>
</Show>
</Card>
))}
</div>
</Show>
{isLoading() ? (
<div class="space-y-3">
{[...Array(3)].map(() => (
<div class="bg-[#2c2c2e] rounded-lg p-4 animate-pulse">
<div class="h-4 bg-[#404040] rounded mb-2"></div>
<div class="h-3 bg-[#404040] rounded w-3/4"></div>
<Show when={!isLoading()}>
<div class="space-y-4">
<For each={filteredNotes()}>
{(note) => (
<Card
data-note-id={note.id}
class={`p-6 cursor-pointer transition-colors hover:bg-accent/50 ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
onClick={() => viewNote(note)}
>
<div class="flex justify-between items-start mb-3 gap-3">
<div class="flex items-center gap-2 min-w-0">
<h3 class="text-lg font-semibold text-foreground truncate">{note.title}</h3>
<Show when={note.pinned}>
<IconPin class="size-4 text-primary" />
</Show>
<Show when={note.isMarkdown}>
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>
</Show>
<Show when={note.isHtml}>
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>
</Show>
</div>
))}
</div>
) : (
<>
{filteredNotes().length > 0 ? (
filteredNotes().map((note) => (
<div
data-note-id={note.id}
class={`bg-[#2c2c2e] hover:bg-[#35363c] rounded-lg p-4 cursor-pointer transition-all border border-transparent ${note.pinned ? 'border-l-4 border-l-[#5865f2]' : ''}`}
onClick={() => viewNote(note)}
<div class="flex gap-1 shrink-0">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<div class="flex justify-between items-start mb-2">
<div class="flex items-center gap-2">
<h3 class="text-white font-medium">{note.title}</h3>
{note.pinned && <IconPin class="size-4 text-[#faa61a]" />}
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">MD</span>}
{note.isHtml && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">HTML</span>}
</div>
<div class="flex gap-1">
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyNoteContent(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconCopy size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconDownload size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
>
<IconEdit size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
>
<IconPin size={14} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-[#ed4245] hover:text-[#ff6b6b] p-1 h-6 w-6"
>
<IconTrash size={14} />
</Button>
</div>
</div>
{/* Note Preview */}
<div class="text-[#dcddde] text-sm">
<div class="prose prose-invert max-w-none">
<Show
when={expandedNotes().has(note.id)}
fallback={
<div
class="overflow-hidden"
style={{
display: '-webkit-box',
'-webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical',
'max-height': '3em',
'line-height': '1.5em'
}}
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? renderMarkdownPreviewHtml(note.content)
: renderPlainTextPreviewHtml(note.content)
}
/>
}
>
<div
innerHTML={
note.isHtml
? note.content
: note.isMarkdown
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/`(.*?)`/g, '<code class="bg-[#404040] px-1 py-0.5 rounded text-xs">$1</code>')
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#404040] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#404040] pl-3 italic text-[#b9b9b9] mb-2">$1</blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n\n+/g, '</p><p class="mb-2">')
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
}
/>
</Show>
</div>
<button
onClick={(e) => {
e.stopPropagation();
console.log('Show more clicked for note:', note.title);
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-[#5865f2] hover:text-[#7289da] font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
{/* Tags */}
<div class="flex flex-wrap gap-1 mt-3">
<For each={note.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-[#404040] hover:bg-[#4a4b4e] text-[#b9b9b9] hover:text-white text-xs rounded-md transition-colors cursor-pointer"
>
{tag}
</button>
)}
</For>
</div>
{/* Attachments */}
<Show when={note.attachments && note.attachments.length > 0}>
<div class="mt-3">
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-3 text-[#b9b9b9]" />
<span class="text-xs text-[#b9b9b9]">Attachments ({note.attachments?.length || 0})</span>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-[#404040] rounded-md text-xs">
<span class="text-[#dcddde]">{attachment.name}</span>
<span class="text-[#8e9297]">({attachment.size})</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex justify-between items-center mt-3 pt-3 border-t border-[#404040]">
<p class="text-xs text-[#8e9297]">
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
</p>
</div>
</div>
))
) : (
<div class="bg-[#2c2c2e] rounded-lg p-8 text-center">
<div class="text-6xl mb-4">📝</div>
<p class="text-[#b9b9b9] text-lg font-medium mb-2">
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
</p>
<p class="text-[#8e9297] text-sm">
{searchTerm() || selectedTags().length > 0
? 'Try adjusting your search terms or filters.'
: 'Click the "Add Note" button to get started.'}
</p>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
exportNote(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<IconDownload size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditNote(note);
}}
class="text-muted-foreground hover:text-foreground p-1"
>
<IconEdit size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
class="text-primary hover:text-primary/80 p-1"
{...{ title: note.pinned ? 'Unpin note' : 'Pin note' }}
>
<IconPin size={16} />
</Button>
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
deleteNote(note.id);
}}
class="text-destructive hover:text-destructive/80 p-1"
>
<IconTrash size={16} />
</Button>
</div>
)}
</>
</div>
<div class="text-muted-foreground text-sm mb-3">
<div class={expandedNotes().has(note.id) ? '' : 'max-h-72 overflow-hidden'}>
<NoteContentRenderer
content={note.content}
kind={getNoteKind(note)}
preview={!expandedNotes().has(note.id)}
maxBlocks={4}
onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)}
/>
</div>
<button
onClick={(e) => {
e.stopPropagation();
toggleNoteExpansion(note.id);
}}
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
>
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
</button>
</div>
<Show when={note.attachments && note.attachments.length > 0}>
<div class="mb-3">
<div class="flex items-center gap-2 mb-2">
<IconPaperclip class="size-4 text-muted-foreground" />
<span class="text-xs text-muted-foreground">Attachments ({note.attachments?.length || 0})</span>
</div>
<div class="flex flex-wrap gap-2">
<For each={note.attachments || []}>
{(attachment) => (
<div class="flex items-center gap-2 px-2 py-1 bg-muted rounded-md text-xs">
<span class="text-foreground">{attachment.name}</span>
<span class="text-muted-foreground">({attachment.size})</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex flex-wrap gap-2 mb-3">
<For each={note.tags}>
{(tag) => (
<button
onClick={(e) => {
e.stopPropagation();
toggleTag(tag);
}}
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
>
{tag}
</button>
)}
</For>
</div>
<p class="text-muted-foreground text-xs">
Updated: {formatDisplayDate(note.updatedAt)}
</p>
</Card>
)}
</div>
</For>
<Show when={filteredNotes().length === 0}>
<Card class="p-12 text-center">
<p class="text-muted-foreground">
{searchTerm() || selectedTags().length > 0
? 'No notes found matching your search or filters.'
: 'No notes yet. Add your first note!'}
</p>
</Card>
</Show>
</div>
</div>
</Show>
{/* Add Note Modal */}
<NoteModal
+115 -7
View File
@@ -1,14 +1,34 @@
import { createSignal, onMount, Show, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { useAuth } from '@/lib/auth';
import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs';
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
import { Button } from '@/components/ui/Button';
import { AIProviderIcon } from '@/components/AIProviderIcon';
import { useHaptics } from '@/lib/haptics';
import { getApiV1BaseUrl } from '@/lib/api-url';
interface BrowserExtensionApiKey {
id: number;
name: string;
permissions: string[];
is_active: boolean;
last_used?: string;
}
interface BrowserExtensionClient {
id: number;
extension_id: string;
name: string;
is_active: boolean;
last_seen?: string;
}
export const Settings = () => {
const { authState, updateProfile, changePassword } = useAuth();
const navigate = useNavigate();
const haptics = useHaptics();
const apiBaseUrl = getApiV1BaseUrl();
const [isLoading, setIsLoading] = createSignal(false);
const [message, setMessage] = createSignal('');
const [profileData, setProfileData] = createSignal({
@@ -128,6 +148,8 @@ export const Settings = () => {
const [emailSettingsExpanded, setEmailSettingsExpanded] = createSignal(true);
const [aiLoading, setAiLoading] = createSignal(false);
const [activeTab, setActiveTab] = createSignal('account');
const [browserExtensionApiKeys, setBrowserExtensionApiKeys] = createSignal<BrowserExtensionApiKey[]>([]);
const [browserExtensions, setBrowserExtensions] = createSignal<BrowserExtensionClient[]>([]);
const tabs = [
{ id: 'account', name: 'Account', icon: IconUser },
@@ -168,6 +190,7 @@ export const Settings = () => {
loadAISettings();
loadAvailableAIProviders();
loadSearchSettings();
loadBrowserExtensionAccess();
});
@@ -235,6 +258,34 @@ export const Settings = () => {
}
};
const loadBrowserExtensionAccess = async () => {
try {
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
const headers = {
'Authorization': `Bearer ${token}`,
};
const [apiKeysResponse, extensionsResponse] = await Promise.all([
fetch(`${apiBaseUrl}/browser-extension/api-keys`, { headers }),
fetch(`${apiBaseUrl}/browser-extension/extensions`, { headers }),
]);
if (apiKeysResponse.ok) {
const keys = await apiKeysResponse.json();
setBrowserExtensionApiKeys(Array.isArray(keys) ? keys : []);
}
if (extensionsResponse.ok) {
const extensions = await extensionsResponse.json();
setBrowserExtensions(Array.isArray(extensions) ? extensions : []);
}
} catch (error) {
console.error('Failed to load browser extension access:', error);
setBrowserExtensionApiKeys([]);
setBrowserExtensions([]);
}
};
const handleUpdateAISettings = async () => {
setAiLoading(true);
setMessage('');
@@ -1685,7 +1736,6 @@ export const Settings = () => {
{/* Tools Tab */}
<Show when={activeTab() === 'tools'}>
<div class="space-y-6">
{/* Browser Extension Download */}
<div class="border rounded-lg p-6">
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
<IconDownload class="size-5" />
@@ -1693,13 +1743,72 @@ export const Settings = () => {
</h2>
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Download the Trackeep browser extension for seamless bookmarking and productivity.
The extension authenticates with a Trackeep browser-extension API key. Download the extension, then connect it with a key from your account.
</p>
<div class="grid grid-cols-1 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Active API Keys</p>
<p class="text-2xl font-semibold text-foreground">
{browserExtensionApiKeys().filter((key) => key.is_active).length}
</p>
<p class="text-xs text-muted-foreground mt-1">
{browserExtensionApiKeys()[0]?.name || 'No extension key created yet'}
</p>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Connected Extensions</p>
<p class="text-2xl font-semibold text-foreground">
{browserExtensions().filter((extension) => extension.is_active).length}
</p>
<p class="text-xs text-muted-foreground mt-1">
{browserExtensions()[0]?.name || 'No extension connected yet'}
</p>
</div>
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Last Activity</p>
<p class="text-sm font-medium text-foreground">
{browserExtensions()[0]?.last_seen
? new Date(browserExtensions()[0].last_seen as string).toLocaleString()
: browserExtensionApiKeys()[0]?.last_used
? new Date(browserExtensionApiKeys()[0].last_used as string).toLocaleString()
: 'No extension activity yet'}
</p>
<p class="text-xs text-muted-foreground mt-1">
Paste an API key into the extension options after installation.
</p>
</div>
</div>
<div class="rounded-xl border border-primary/20 bg-primary/5 p-4">
<h4 class="font-medium text-foreground mb-2">Connection flow</h4>
<div class="space-y-2 text-sm text-muted-foreground">
<div class="flex items-start gap-2">
<span class="text-primary">1.</span>
<span>Download and unpack the extension.</span>
</div>
<div class="flex items-start gap-2">
<span class="text-primary">2.</span>
<span>Create or reuse a browser-extension API key in Trackeep.</span>
</div>
<div class="flex items-start gap-2">
<span class="text-primary">3.</span>
<span>Paste that key into the extension settings to connect bookmarks, files, notes, and tasks.</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<Button
onClick={() => navigate('/app/browser-extension')}
variant="default"
class="flex items-center gap-2"
>
<IconKey class="size-4" />
Manage API Keys
</Button>
<Button
onClick={() => {
// Create a download link for the extension zip
const link = document.createElement('a');
link.href = '/browser-extension';
link.download = 'browser-extension.zip';
@@ -1707,14 +1816,13 @@ export const Settings = () => {
link.click();
document.body.removeChild(link);
}}
variant="default"
variant="outline"
class="flex items-center gap-2"
>
<IconDownload class="size-4" />
Download Extension (ZIP)
</Button>
</div>
<div class="bg-muted/30 rounded-lg p-4">
<h4 class="font-medium text-foreground mb-2">Installation Instructions:</h4>
<div class="space-y-2 text-sm text-muted-foreground">
+257
View File
@@ -0,0 +1,257 @@
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { toast } from '@/components/ui/Toast';
import { getApiV1BaseUrl } from '@/lib/api-url';
import { useNavigate } from '@solidjs/router';
import { createSignal, onMount, Show } from 'solid-js';
const API_BASE_URL = getApiV1BaseUrl();
const PENDING_SHARE_KEY = 'trackeep_pending_share_target';
interface PendingSharePayload {
title: string;
text: string;
url: string;
}
function extractFirstUrl(text: string): string | null {
const match = text.match(/https?:\/\/[^\s<>"')\]]+/i);
if (!match) {
return null;
}
return match[0].replace(/[),.;!?]+$/, '');
}
function normalizeUrl(url: string): string | null {
const trimmed = url.trim();
if (!trimmed) {
return null;
}
try {
return new URL(trimmed).toString();
} catch {
return null;
}
}
function deriveTitle(url: string, providedTitle: string): string {
const title = providedTitle.trim();
if (title) {
return title;
}
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return 'Shared Link';
}
}
function isGenericTitle(title: string, url: string): boolean {
const normalized = title.trim().toLowerCase();
if (!normalized) {
return true;
}
if (normalized === 'shared link' || normalized === 'youtube video') {
return true;
}
try {
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
return normalized === host;
} catch {
return false;
}
}
function deriveDescription(sharedText: string, url: string): string {
const cleaned = sharedText.replace(url, '').trim();
return cleaned.slice(0, 1200);
}
function looksLikeYouTube(url: string): boolean {
try {
const host = new URL(url).hostname.toLowerCase();
return host.includes('youtube.com') || host.includes('youtu.be');
} catch {
return false;
}
}
function writePendingShare(payload: PendingSharePayload): void {
sessionStorage.setItem(PENDING_SHARE_KEY, JSON.stringify(payload));
}
function readPendingShare(): PendingSharePayload | null {
const raw = sessionStorage.getItem(PENDING_SHARE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PendingSharePayload>;
return {
title: typeof parsed.title === 'string' ? parsed.title : '',
text: typeof parsed.text === 'string' ? parsed.text : '',
url: typeof parsed.url === 'string' ? parsed.url : '',
};
} catch {
return null;
}
}
function clearPendingShare(): void {
sessionStorage.removeItem(PENDING_SHARE_KEY);
}
export const ShareTarget = () => {
const navigate = useNavigate();
const [status, setStatus] = createSignal<'processing' | 'success' | 'error' | 'auth'>('processing');
const [message, setMessage] = createSignal('Saving shared content to Trackeep...');
onMount(async () => {
const params = new URLSearchParams(window.location.search);
const incomingPayload: PendingSharePayload = {
title: params.get('title') || '',
text: params.get('text') || '',
url: params.get('url') || '',
};
const hasIncomingPayload = Boolean(incomingPayload.title || incomingPayload.text || incomingPayload.url);
if (hasIncomingPayload) {
writePendingShare(incomingPayload);
}
const payload = hasIncomingPayload ? incomingPayload : readPendingShare();
if (!payload) {
setStatus('error');
setMessage('No shared content detected.');
return;
}
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
if (!token) {
setStatus('auth');
setMessage('Sign in required. Redirecting to login...');
toast.info('Sign in required', 'Please sign in to save shared content.');
navigate('/login?next=%2Fshare-target', { replace: true });
return;
}
const rawUrl = payload.url || extractFirstUrl(payload.text) || '';
const normalizedUrl = normalizeUrl(rawUrl);
if (!normalizedUrl) {
setStatus('error');
setMessage('No valid URL found in shared content.');
toast.error('Share failed', 'No valid URL found in shared payload.');
clearPendingShare();
return;
}
let title = deriveTitle(normalizedUrl, payload.title);
let description = deriveDescription(payload.text, normalizedUrl);
try {
const metadataResponse = await fetch(`${API_BASE_URL}/bookmarks/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ url: normalizedUrl }),
});
if (metadataResponse.ok) {
const metadata = await metadataResponse
.json()
.catch(() => ({})) as { title?: string; description?: string };
if (
typeof metadata.title === 'string' &&
metadata.title.trim().length > 0 &&
isGenericTitle(title, normalizedUrl)
) {
title = metadata.title.trim();
}
if (
typeof metadata.description === 'string' &&
metadata.description.trim().length > 0 &&
description.trim().length < 12
) {
description = metadata.description.trim();
}
}
} catch {
// metadata enrichment is best-effort
}
try {
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title,
url: normalizedUrl,
description,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed with status ${response.status}`);
}
clearPendingShare();
setStatus('success');
const destination = looksLikeYouTube(normalizedUrl) ? '/app/youtube' : '/app/bookmarks';
setMessage(
looksLikeYouTube(normalizedUrl)
? 'Saved successfully. Redirecting to YouTube...'
: 'Saved successfully. Redirecting to bookmarks...',
);
toast.success('Saved to Trackeep', 'Shared link was added to bookmarks.');
setTimeout(() => {
navigate(destination, { replace: true });
}, 600);
} catch (error) {
const text = error instanceof Error ? error.message : 'Failed to save shared content';
setStatus('error');
setMessage(text);
toast.error('Share failed', text);
}
});
return (
<div class="min-h-[50vh] flex items-center justify-center p-4">
<Card class="w-full max-w-md p-6 space-y-4 border-border bg-card text-card-foreground">
<h1 class="text-xl font-semibold">Trackeep Share Target</h1>
<p class="text-sm text-muted-foreground">{message()}</p>
<Show when={status() === 'processing'}>
<div class="inline-block w-6 h-6 border-2 border-primary border-r-transparent rounded-full animate-spin" />
</Show>
<Show when={status() !== 'processing'}>
<div class="flex gap-2">
<Button
onClick={() => navigate(status() === 'auth' ? '/login' : '/app/bookmarks', { replace: true })}
>
{status() === 'auth' ? 'Open Login' : 'Open Bookmarks'}
</Button>
<Button variant="outline" onClick={() => navigate('/app', { replace: true })}>
Go Home
</Button>
</div>
</Show>
</Card>
</div>
);
};