Files
beaver-land/src/lib/components/PromptBuilder/PromptCard.svelte
2026-01-06 05:22:48 +01:00

352 lines
8.3 KiB
Svelte

<script lang="ts">
import { getI18n, type PromptPreset } from '$lib/i18n';
import { generateRaycastUrl, getPrompt } from '$lib/utils/raycast';
interface Props {
preset: PromptPreset;
}
let { preset }: Props = $props();
const i18n = getI18n();
const t = $derived(i18n.t);
let copied = $state(false);
const promptText = $derived(getPrompt(i18n.lang === 'ru' ? 'Russian' : 'English'));
const raycastUrl = $derived(
preset.id !== 'copy'
? generateRaycastUrl({
name: preset.name,
instructions: promptText,
model: preset.model,
reasoning_effort: preset.reasoning_effort,
creativity: 'none',
web_search: true,
})
: ''
);
async function copyPrompt() {
try {
await navigator.clipboard.writeText(promptText);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
function getIcon(iconName: string): string {
switch (iconName) {
case 'beaver':
return '匠'; // master craftsman / professional
case 'zap':
return '閃'; // flash / lightning
case 'clipboard':
return '写'; // copy / transcribe
default:
return '道';
}
}
const cardVariant = $derived(
preset.id === 'pro' ? 'pro' : preset.id === 'flash' ? 'flash' : 'copy'
);
let svgElement: SVGSVGElement | null = $state(null);
let isHovering = $state(false);
let isTransitioningOut = $state(false);
function handleMouseEnter() {
isHovering = true;
isTransitioningOut = false;
}
function handleMouseLeave() {
if (!svgElement) {
isHovering = false;
return;
}
// Grab current animated values
const computed = getComputedStyle(svgElement);
const currentTransform = computed.transform;
const currentStrokeWidth = computed.strokeWidth;
// Apply them as inline styles
svgElement.style.transform = currentTransform === 'none' ? '' : currentTransform;
svgElement.style.strokeWidth = currentStrokeWidth;
// Stop animation, start transition
isHovering = false;
isTransitioningOut = true;
// Remove inline styles after a frame to let transition kick in
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (svgElement) {
svgElement.style.transform = '';
svgElement.style.strokeWidth = '';
}
// Reset after transition completes
setTimeout(() => {
isTransitioningOut = false;
}, 1000);
});
});
}
</script>
{#snippet cardContent()}
<header class="card-header">
<div class="card-icon">
<span class="icon-text">{getIcon(preset.icon)}</span>
</div>
<h3 class="card-title">{preset.name}</h3>
<span class="action-button" aria-hidden="true">
{#if preset.id === 'copy'}
{#if copied}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
{/if}
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M9 6l6 6-6 6"/>
</svg>
{/if}
</span>
</header>
<div class="divider"></div>
<p class="card-description">{preset.description}</p>
<!-- Decorative illustration -->
<div class="card-illustration" class:is-hovering={isHovering}>
{#if cardVariant === 'pro'}
<!-- Brain icon from Tabler Icons -->
<svg bind:this={svgElement} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="illustration-svg" class:breathing={isHovering}>
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8"/>
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8"/>
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5"/>
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0"/>
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5"/>
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10"/>
</svg>
{:else if cardVariant === 'flash'}
<!-- Lightning bolt -->
<svg bind:this={svgElement} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="illustration-svg" class:breathing={isHovering}>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
{:else}
<!-- @ symbol -->
<svg bind:this={svgElement} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="illustration-svg" class:breathing={isHovering}>
<circle cx="12" cy="12" r="4"/>
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>
</svg>
{/if}
</div>
{/snippet}
{#if preset.id === 'copy'}
<div
class="prompt-card variant-{cardVariant}"
role="button"
tabindex="0"
onclick={copyPrompt}
onkeydown={(e) => e.key === 'Enter' && copyPrompt()}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
{@render cardContent()}
</div>
{:else}
<a
href={raycastUrl}
class="prompt-card variant-{cardVariant}"
target="_blank"
rel="noopener noreferrer"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
{@render cardContent()}
</a>
{/if}
<style>
.prompt-card {
position: relative;
display: flex;
flex-direction: column;
padding: 1.5rem;
gap: 1rem;
text-decoration: none;
cursor: pointer;
min-height: 420px;
border-radius: 16px;
overflow: hidden;
}
/* Darkening overlay */
.prompt-card::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.25);
opacity: 0;
transition: opacity 1s ease;
z-index: 1;
pointer-events: none;
}
.prompt-card:hover::before {
opacity: 1;
}
/* Card variants with unique gradients */
.variant-pro {
background: linear-gradient(145deg, #191B43 0%, #0E0A2A 100%);
border: 1px solid rgba(25, 27, 67, 0.5);
}
.variant-flash {
background: linear-gradient(145deg, #122241 0%, #1E417A 100%);
border: 1px solid rgba(30, 65, 122, 0.5);
}
.variant-copy {
background: linear-gradient(145deg, #321134 0%, #260C28 100%);
border: 1px solid rgba(50, 17, 52, 0.5);
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
z-index: 2;
}
.card-icon {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.icon-text {
font-size: 20px;
font-weight: bold;
color: white;
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
flex: 1;
min-width: 0;
}
.action-button {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
flex-shrink: 0;
cursor: pointer;
transition: background-color 1s ease, color 1s ease;
}
.prompt-card:hover .action-button {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
position: relative;
z-index: 2;
}
.card-description {
font-size: 1rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
line-height: 1.7;
position: relative;
z-index: 2;
}
/* Decorative illustration - 50% of card, overflows edge */
.card-illustration {
position: absolute;
bottom: -25%;
right: -10%;
width: 85%;
height: 85%;
pointer-events: none;
opacity: 0.1;
transition: opacity 1s ease;
}
.card-illustration.is-hovering {
opacity: 0.18;
}
.illustration-svg {
width: 100%;
height: 100%;
stroke-width: 1.2;
transform: scale(1) translateY(0);
transition: transform 1s ease, stroke-width 1s ease;
}
.illustration-svg.breathing {
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% {
transform: scale(1) translateY(0);
stroke-width: 1.2;
}
50% {
transform: scale(1.05) translateY(-2%);
stroke-width: 1.8;
}
}
.variant-pro .illustration-svg {
stroke: rgba(147, 130, 255, 0.9);
}
.variant-flash .illustration-svg {
stroke: rgba(100, 180, 255, 0.9);
}
.variant-copy .illustration-svg {
stroke: rgba(255, 130, 200, 0.9);
}
</style>