generated from nhcarrigan/template
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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<Blob> {
|
||||
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<void>((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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -76,8 +322,8 @@
|
||||
<!-- Avatar Section -->
|
||||
<div class="avatar-section">
|
||||
<div class="avatar-container" on:click={selectAvatar} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && selectAvatar()}>
|
||||
{#if config.profile_avatar_path}
|
||||
<img src={getAvatarSrc(config.profile_avatar_path)} alt="Profile avatar" class="avatar-image" />
|
||||
{#if avatarDataUrl}
|
||||
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
|
||||
<div class="avatar-overlay">
|
||||
<span>Change</span>
|
||||
</div>
|
||||
@@ -178,6 +424,24 @@
|
||||
<span class="progress-text">{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Section -->
|
||||
<div class="share-section">
|
||||
<button class="share-btn btn-trans-gradient" on:click={shareProfile} disabled={isGeneratingImage}>
|
||||
{#if isGeneratingImage}
|
||||
<span class="spinner"></span>
|
||||
Generating...
|
||||
{:else}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
<polyline points="16 6 12 2 8 6" />
|
||||
<line x1="12" y1="2" x2="12" y2="15" />
|
||||
</svg>
|
||||
Share Profile
|
||||
{/if}
|
||||
</button>
|
||||
<p class="share-hint">Generate a shareable image of your profile</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user