From 73d66e9ae4ae786eeeca85b0b23cd67d5bae908e Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 25 Jan 2026 20:30:47 -0800 Subject: [PATCH] feat: add shareable profile image generation - Add 1920x1080 HD canvas with trans-pride themed design - Include profile avatar, name, bio, and lifetime stats - Show achievement progress with visual progress bar - Use Tauri fs plugin for cross-platform file reading - Add scoped file permissions for read/write access --- src-tauri/capabilities/default.json | 8 + src/lib/components/ProfilePanel.svelte | 334 ++++++++++++++++++++++++- 2 files changed, 333 insertions(+), 9 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 00d73b4..f8363c3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,14 @@ "fs:default", "fs:allow-read-text-file", "fs:allow-write-text-file", + { + "identifier": "fs:allow-read-file", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-write-file", + "allow": [{ "path": "**" }] + }, "core:window:allow-set-size", "core:window:allow-set-always-on-top", "core:window:allow-inner-size" diff --git a/src/lib/components/ProfilePanel.svelte b/src/lib/components/ProfilePanel.svelte index ec0de05..4523ec7 100644 --- a/src/lib/components/ProfilePanel.svelte +++ b/src/lib/components/ProfilePanel.svelte @@ -2,8 +2,8 @@ import { configStore, type HikariConfig } from "$lib/stores/config"; import { formattedStats } from "$lib/stores/stats"; import { achievementsStore } from "$lib/stores/achievements"; - import { open } from "@tauri-apps/plugin-dialog"; - import { convertFileSrc } from "@tauri-apps/api/core"; + import { open, save } from "@tauri-apps/plugin-dialog"; + import { writeFile, readFile } from "@tauri-apps/plugin-fs"; export let onClose: () => void; @@ -12,8 +12,32 @@ let editingName = false; let editingBio = false; - let nameInput = config.profile_name || ""; - let bioInput = config.profile_bio || ""; + let nameInput = ""; + let bioInput = ""; + let avatarDataUrl: string | null = null; + + // Initialize inputs when config is loaded + $: if (config) { + if (!editingName) nameInput = config.profile_name || ""; + if (!editingBio) bioInput = config.profile_bio || ""; + } + + // Load avatar on mount and when path changes + let lastLoadedPath: string | null = null; + + async function updateAvatarDisplay(path: string | null) { + if (path === lastLoadedPath) return; + lastLoadedPath = path; + if (path) { + avatarDataUrl = await loadAvatarAsDataUrl(path); + } else { + avatarDataUrl = null; + } + } + + $: updateAvatarDisplay(config?.profile_avatar_path ?? null); + + let isGeneratingImage = false; $: unlockedCount = Object.values($achievementsStore.achievements).filter((a) => a.unlocked).length; $: totalAchievements = Object.values($achievementsStore.achievements).length; @@ -55,9 +79,231 @@ editingBio = false; } - function getAvatarSrc(path: string | null): string | null { - if (!path) return null; - return convertFileSrc(path); + async function loadAvatarAsDataUrl(path: string): Promise { + try { + const data = await readFile(path); + const extension = path.split('.').pop()?.toLowerCase() || 'png'; + const mimeType = extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg' + : extension === 'gif' ? 'image/gif' + : extension === 'webp' ? 'image/webp' + : 'image/png'; + // Convert Uint8Array to base64 in chunks to avoid stack overflow + const blob = new Blob([data], { type: mimeType }); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch (error) { + console.error("Failed to load avatar:", error); + return null; + } + } + + async function generateShareImage(): Promise { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + // Card dimensions (1080p for sharing) + const width = 1920; + const height = 1080; + canvas.width = width; + canvas.height = height; + + // Background gradient (dark theme) + const bgGradient = ctx.createLinearGradient(0, 0, width, height); + bgGradient.addColorStop(0, "#1a1a2e"); + bgGradient.addColorStop(1, "#16213e"); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, width, height); + + // Trans flag stripe accent at top + const stripeHeight = 25; + const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"]; + stripeColors.forEach((color, i) => { + ctx.fillStyle = color; + ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, stripeHeight / 5 * 2); + }); + + // Border + ctx.strokeStyle = "#5bcefa"; + ctx.lineWidth = 6; + ctx.strokeRect(3, 3, width - 6, height - 6); + + // Avatar circle + const avatarX = 200; + const avatarY = 220; + const avatarRadius = 140; + + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + // Draw avatar if available, otherwise gradient placeholder + let avatarLoaded = false; + if (config.profile_avatar_path) { + try { + const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path); + if (dataUrl) { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(); + img.src = dataUrl; + }); + ctx.drawImage(img, avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2); + avatarLoaded = true; + } + } catch { + // Will use fallback gradient + } + } + + if (!avatarLoaded) { + const avatarGradient = ctx.createLinearGradient(avatarX - avatarRadius, avatarY - avatarRadius, avatarX + avatarRadius, avatarY + avatarRadius); + avatarGradient.addColorStop(0, "#5bcefa"); + avatarGradient.addColorStop(0.5, "#f5a9b8"); + avatarGradient.addColorStop(1, "#5bcefa"); + ctx.fillStyle = avatarGradient; + ctx.fillRect(avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2); + } + ctx.restore(); + + // Avatar border + ctx.beginPath(); + ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2); + ctx.strokeStyle = "#f5a9b8"; + ctx.lineWidth = 8; + ctx.stroke(); + + // Name + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 72px system-ui, -apple-system, sans-serif"; + ctx.fillText(config.profile_name || "Hikari User", 400, 180); + + // Bio (truncated) + ctx.fillStyle = "#9ca3af"; + ctx.font = "36px system-ui, -apple-system, sans-serif"; + const bio = config.profile_bio || "A Hikari Desktop user"; + const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio; + ctx.fillText(truncatedBio, 400, 260); + + // Stats section + const statsY = 420; + ctx.fillStyle = "#6b7280"; + ctx.font = "bold 32px system-ui, -apple-system, sans-serif"; + ctx.fillText("LIFETIME STATS", 80, statsY); + + // Stats grid + const stats = [ + { label: "Messages", value: $formattedStats.messagesTotal }, + { label: "Tokens", value: $formattedStats.totalTokens }, + { label: "Code Blocks", value: $formattedStats.codeBlocksTotal }, + { label: "Files Edited", value: $formattedStats.filesEditedTotal }, + { label: "Files Created", value: $formattedStats.filesCreatedTotal }, + { label: "Total Cost", value: $formattedStats.totalCost }, + ]; + + const statBoxWidth = 540; + const statBoxHeight = 160; + const statsPerRow = 3; + const startX = 80; + const startY = statsY + 40; + + stats.forEach((stat, i) => { + const row = Math.floor(i / statsPerRow); + const col = i % statsPerRow; + const x = startX + col * (statBoxWidth + 50); + const y = startY + row * (statBoxHeight + 30); + + // Stat box background + ctx.fillStyle = "rgba(255, 255, 255, 0.05)"; + ctx.beginPath(); + ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20); + ctx.fill(); + + // Stat value + ctx.fillStyle = "#5bcefa"; + ctx.font = "bold 56px system-ui, -apple-system, sans-serif"; + ctx.fillText(stat.value, x + 32, y + 75); + + // Stat label + ctx.fillStyle = "#9ca3af"; + ctx.font = "28px system-ui, -apple-system, sans-serif"; + ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128); + }); + + // Achievement progress + const achieveY = 870; + ctx.fillStyle = "#6b7280"; + ctx.font = "bold 32px system-ui, -apple-system, sans-serif"; + ctx.fillText("ACHIEVEMENTS", 80, achieveY); + + // Progress bar background + const progressX = 80; + const progressY = achieveY + 30; + const progressWidth = 1200; + const progressHeight = 44; + + ctx.fillStyle = "rgba(255, 255, 255, 0.1)"; + ctx.beginPath(); + ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22); + ctx.fill(); + + // Progress bar fill + const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0); + progressGradient.addColorStop(0, "#5bcefa"); + progressGradient.addColorStop(0.5, "#f5a9b8"); + progressGradient.addColorStop(1, "#5bcefa"); + ctx.fillStyle = progressGradient; + ctx.beginPath(); + ctx.roundRect(progressX, progressY, progressWidth * (achievementPercentage / 100), progressHeight, 22); + ctx.fill(); + + // Progress text + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 36px system-ui, -apple-system, sans-serif"; + ctx.fillText(`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`, progressX + progressWidth + 40, progressY + 34); + + // Hikari branding + ctx.fillStyle = "#f5a9b8"; + ctx.font = "bold 42px system-ui, -apple-system, sans-serif"; + ctx.fillText("✨ Hikari Desktop", 80, height - 100); + + // Discord promo + ctx.fillStyle = "#9ca3af"; + ctx.font = "34px system-ui, -apple-system, sans-serif"; + ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45); + + // Convert canvas to blob + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob!), "image/png"); + }); + } + + async function shareProfile() { + isGeneratingImage = true; + try { + const blob = await generateShareImage(); + const arrayBuffer = await blob.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + const filePath = await save({ + filters: [{ name: "PNG Image", extensions: ["png"] }], + defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`, + }); + + if (filePath) { + await writeFile(filePath, uint8Array); + } + } catch (error) { + console.error("Failed to generate share image:", error); + } finally { + isGeneratingImage = false; + } } @@ -76,8 +322,8 @@
e.key === 'Enter' && selectAvatar()}> - {#if config.profile_avatar_path} - Profile avatar + {#if avatarDataUrl} + Profile avatar
Change
@@ -178,6 +424,24 @@ {unlockedCount} / {totalAchievements} ({achievementPercentage}%)
+ + + @@ -514,4 +778,56 @@ color: var(--text-secondary); text-align: center; } + + /* Share */ + .share-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + } + + .share-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: all 0.2s; + } + + .share-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + .share-btn svg { + flex-shrink: 0; + } + + .share-hint { + margin: 0; + font-size: 12px; + color: var(--text-secondary); + } + + .spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + }