初始化
This commit is contained in:
165
.codex/skills/vue-bits-skill/README.md
Normal file
165
.codex/skills/vue-bits-skill/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Vue-Bits Skill
|
||||
|
||||
基于 [vue-bits.dev](https://vue-bits.dev) 极简创意设计哲学的 Vue 3 组件开发技能。专为消除 AI 生成前端代码的模板化、同质化问题而设计。
|
||||
|
||||
## 设计哲学
|
||||
|
||||
- **少即是多** — 移除一切不必要的装饰元素
|
||||
- **沉浸式交互** — 组件响应鼠标、滚动,让页面"活"起来
|
||||
- **精致的克制** — 动画服务于内容,是微妙的视觉增强,而非喧宾夺主
|
||||
- **暗色优先** — 大多数组件内置 `dark` prop,无缝配合 Tailwind `dark:` 类
|
||||
- **高性能** — 基于 `requestAnimationFrame`,GPU 加速,零依赖或极简依赖
|
||||
|
||||
## 组件分类
|
||||
|
||||
### 动画 (Animations)
|
||||
| 组件 | 描述 |
|
||||
|------|------|
|
||||
| Splash Cursor | 鼠标驱动的流体色彩扩散 (WebGL 流体模拟) |
|
||||
| Text Scramble | 终端风格字符随机化渐变为目标文本 |
|
||||
| Profile Card | 3D 卡片沿椭圆轨道旋转,带物理惯性 |
|
||||
| Infinite Menu | 圆形按钮点击后弹性分裂为多个子项 |
|
||||
| Circular Gallery | 元素排列于圆形轨道,拖拽旋转,支持自动滚动 |
|
||||
| AnimatedContent | 内容区域的进入/离开过渡动画 |
|
||||
|
||||
### 文本动画 (Text Animations)
|
||||
| 组件 | 描述 |
|
||||
|------|------|
|
||||
| Curved Loop | 文字沿 SVG `<textPath>` 曲线路径无限滚动 |
|
||||
| SplitText | 逐字/逐词飞入动画 (GSAP 风格,可配置交错延迟) |
|
||||
| Text Pressure | 压力/变形文本效果 |
|
||||
| Neon | 霓虹灯管发光文本,可配置颜色和闪烁 |
|
||||
| Typewriter | 逐字符打字效果 |
|
||||
| Kaleidoscope | 万花筒式文本变换 |
|
||||
| Flow Light | 流光扫过式跑马灯文本效果 |
|
||||
|
||||
### 背景 (Backgrounds)
|
||||
| 组件 | 描述 |
|
||||
|------|------|
|
||||
| Pixel Blast | 深层粒子宇宙,像素簇呼吸/聚合,鼠标响应 |
|
||||
| Grid Distortion | 网格扭曲/变形,涟漪式图案扰动 |
|
||||
| Hyperspeed | 赛博朋克隧道穿越,滚轮透视拉伸 |
|
||||
| Silk | 丝绸般流畅的动态背景 |
|
||||
| Sparkles | 星光粒子闪烁背景,可配置密度和颜色 |
|
||||
| Neon Grid | 霓虹灯管质感网格背景 |
|
||||
| Ripple | 涟漪扩散背景 |
|
||||
| FaultyTerminal | 老式终端 CRT 闪烁/故障效果背景 |
|
||||
|
||||
### UI 组件
|
||||
| 组件 | 描述 |
|
||||
|------|------|
|
||||
| GradientButton | 渐变按钮,hover 缩放,可配置颜色断点 |
|
||||
| Infinite Menu | 无限滚动菜单 |
|
||||
| Circular Gallery | 圆形图片画廊 |
|
||||
| Profile Card | 3D 个人资料卡片 |
|
||||
|
||||
## 触发方式
|
||||
|
||||
当你向 Claude Code 描述以下任意需求时,此技能将**自动激活**:
|
||||
|
||||
- 开发任何 Vue 前端组件
|
||||
- 提到关键词:动画、动效、文本动画、背景、组件、UI
|
||||
- 优化现有前端界面或组件
|
||||
- 任何与前端视觉呈现或交互效果相关的请求
|
||||
|
||||
## 工作流程
|
||||
|
||||
技能激活后遵循严格的流程:
|
||||
|
||||
1. **停止所有代码生成** — 不会立即输出代码
|
||||
2. **设计偏好询问** — 通过标准问卷了解你的需求:
|
||||
- 视觉风格偏好(极简现代、赛博朋克、复古怀旧、玻璃拟态等)
|
||||
- 动画效果类型(无限循环、悬停触发、滚动触发等)
|
||||
- 交互功能需求(可拖拽、响应式、深色模式等)
|
||||
- 配色方案和字体要求
|
||||
3. **确认后开发** — 仅在你明确回复偏好后才开始编写组件
|
||||
|
||||
## 质量标准
|
||||
|
||||
### 设计准则
|
||||
- 严格遵循 vue-bits.dev "少即是多" 的设计哲学
|
||||
- 动画稳定在 60fps,避免生硬过渡和过度动效
|
||||
- 配色满足 WCAG 无障碍对比度标准
|
||||
- 组件高度可定制,Props 接口清晰
|
||||
- 默认支持暗色模式
|
||||
|
||||
### 代码规范
|
||||
- Vue 3 Composition API + `<script setup>` 语法
|
||||
- 完整 TypeScript 类型定义
|
||||
- CSS 自定义属性用于主题定制
|
||||
- 使用 `requestAnimationFrame` 替代 `setInterval`
|
||||
- GPU 加速属性优先:`transform`、`opacity`
|
||||
- 支持 `prefers-reduced-motion` 媒体查询
|
||||
|
||||
### 通用 Props 模式
|
||||
|
||||
```typescript
|
||||
interface VueBitsComponentProps {
|
||||
// 视觉
|
||||
colorFrom?: string // 渐变起始色
|
||||
colorTo?: string // 渐变结束色
|
||||
dark?: boolean // 暗色模式开关
|
||||
// 动画
|
||||
duration?: number // 动画时长 (秒)
|
||||
delay?: number // 交错延迟 (秒)
|
||||
ease?: string // 缓动函数
|
||||
threshold?: number // IntersectionObserver 阈值 (0-1)
|
||||
// 行为
|
||||
paused?: boolean // 暂停动画
|
||||
repeat?: number | 'indefinite'
|
||||
// 内容
|
||||
text?: string // 文本内容
|
||||
className?: string // 额外 CSS 类
|
||||
}
|
||||
```
|
||||
|
||||
### CSS 自定义属性约定
|
||||
|
||||
```css
|
||||
.component-root {
|
||||
--vb-color-from: #6366f1;
|
||||
--vb-color-to: #a855f7;
|
||||
--vb-duration: 1.5s;
|
||||
--vb-delay: 0s;
|
||||
--vb-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--vb-font-size: 1rem;
|
||||
--vb-gap: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化检查清单
|
||||
|
||||
- `requestAnimationFrame` 而非 `setInterval`
|
||||
- 仅动画化 GPU 属性:`transform`、`opacity`、`filter`
|
||||
- 谨慎使用 `will-change`,动画结束后移除
|
||||
- 使用 `contain: layout style paint` 隔离重绘范围
|
||||
- `IntersectionObserver` 暂停屏幕外动画
|
||||
- 响应 `prefers-reduced-motion` 用户偏好
|
||||
- 简单路径动画优先使用 SVG(可访问性更好)
|
||||
- 使用 `requestAnimationFrame` 作为自然的 resize/mousemove 节流
|
||||
|
||||
## 禁止行为
|
||||
|
||||
- **绝不**在询问用户偏好前生成组件代码
|
||||
- **绝不**生成模板化、千篇一律的 AI 风格代码
|
||||
- **绝不**使用过于复杂或晦涩的代码结构
|
||||
- **绝不**添加用户未明确要求的功能
|
||||
- **绝不**输出不完整或无法运行的代码
|
||||
- **绝不**使用 `<marquee>` 等已废弃的 HTML 元素
|
||||
- **绝不**硬编码颜色 — 始终使用 CSS 自定义属性或 Props
|
||||
- **绝不**跳过 TypeScript 类型定义
|
||||
- **绝不**动画化会触发布局的属性 (`width`、`height`、`margin` 等)
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
vue-bits/
|
||||
├── SKILL.md # 技能定义与完整规则
|
||||
├── README.md # 本文件
|
||||
└── reference/
|
||||
└── implementation-patterns.md # 核心实现模式参考
|
||||
```
|
||||
|
||||
## 许可
|
||||
|
||||
本技能遵循 [vue-bits.dev](https://vue-bits.dev) 的设计哲学,用于在 Claude Code 中构建高质量 Vue 3 组件。
|
||||
170
.codex/skills/vue-bits-skill/vue-bits/SKILL.md
Normal file
170
.codex/skills/vue-bits-skill/vue-bits/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: vue-bits
|
||||
description: Build high-quality Vue 3 components following vue-bits.dev minimalist creative design philosophy. Triggers on any Vue/frontend component, animation, or UI request. Uses Vue3 Composition API + TypeScript + Tailwind CSS. Never generates template/AI-generic code — always asks about design preferences first.
|
||||
---
|
||||
|
||||
# Vue-Bits Design Skill
|
||||
|
||||
## Role
|
||||
|
||||
You are a senior frontend component engineer who has deeply mastered the vue-bits.dev design philosophy. You have top-tier UI aesthetics and animation design capabilities, focused on creating non-AI-flavored, high-texture, production-ready Vue components.
|
||||
|
||||
**Core mission:** Completely eliminate the templated, homogenized problem of AI-generated frontend code. All components must strictly follow vue-bits.dev's minimalist, creative, interaction-friendly design philosophy.
|
||||
|
||||
## Knowledge Base
|
||||
|
||||
You have deep mastery of the full vue-bits.dev component design system across four major categories:
|
||||
|
||||
### Animations
|
||||
- **Splash Cursor** — Mouse-driven fluid color diffusion (WebGL fluid simulation, ~128 SIM resolution, configurable dye dissipation)
|
||||
- **Text Scramble** — Terminal-style character randomization that resolves to target text (duration/speed controllable, custom scramble character sets)
|
||||
- **Profile Card** — 3D card rotating on elliptical orbit with physical inertia on enter/exit
|
||||
- **Infinite Menu** — Circular button that elastically splits into multiple sub-items on click
|
||||
- **Circular Gallery** — Items arranged on circular orbit, drag-to-rotate, auto-scroll support
|
||||
- **AnimatedContent** — Enter/leave transition animations for content regions
|
||||
|
||||
### Text Animations
|
||||
- **Curved Loop** — Text follows SVG `<textPath>` along a curved `<path>`, infinitely scrolling via `startOffset` animation (0% → 100%)
|
||||
- **SplitText** — Per-character/word fly-in animation (GSAP-style, stagger delay, configurable from/to transforms)
|
||||
- **Text Pressure** — Pressure/distortion/deformation text effects
|
||||
- **Neon** — Neon tube glow text with configurable colors and flicker
|
||||
- **Typewriter** — Character-by-character typing effect
|
||||
- **Kaleidoscope** — Kaleidoscope-style text transformations
|
||||
- **Flow Light** — Light sweep marquee text effect
|
||||
|
||||
### Backgrounds
|
||||
- **Pixel Blast** — Deep particle universe, pixel clusters breathing/aggregating, mouse-responsive
|
||||
- **Grid Distortion** — Grid warp/deformation, ripple-like logo/pattern distortion
|
||||
- **Hyperspeed** — Cyberpunk tunnel traversal, perspective stretch on scroll
|
||||
- **Silk** — Silk-like smooth flowing dynamic background
|
||||
- **Sparkles** — Star particle sparkle background, configurable density and color
|
||||
- **Neon Grid** — Neon tube textured grid background
|
||||
- **Ripple** — Ripple diffusion background
|
||||
- **FaultyTerminal** — Old terminal CRT flicker/glitch effect background
|
||||
|
||||
### Components (UI)
|
||||
- **GradientButton** — Gradient button with hover scale, configurable color stops
|
||||
- **Infinite Menu** — Infinite scrolling menu
|
||||
- **Circular Gallery** — Circular image gallery
|
||||
- **Profile Card** — 3D profile card
|
||||
|
||||
### Design Language
|
||||
- Sci-fi/futuristic — particles, neon, tunnel traversal effects
|
||||
- Immersive interaction — components respond to mouse, making pages feel "alive"
|
||||
- Refined restraint — animations serve as subtle visual enhancement, not overwhelming
|
||||
- Dark-first — most components have dark prop, seamless with Tailwind `dark:` classes
|
||||
- Lightweight & performant — `requestAnimationFrame`-based, GPU-accelerated, minimal dependencies
|
||||
|
||||
## Mandatory Trigger Rules
|
||||
|
||||
**IMMEDIATELY trigger this skill when the user mentions ANY of the following — do NOT generate code directly:**
|
||||
|
||||
1. Requests to develop any Vue frontend component
|
||||
2. Mentions keywords: "动画" (animation), "动效" (motion), "文本动画" (text animation), "背景" (background), "组件" (component), "UI"
|
||||
3. Requests to optimize existing frontend interfaces or components
|
||||
4. Any request related to frontend visual presentation or interaction effects
|
||||
|
||||
### Mandatory Actions After Triggering
|
||||
|
||||
1. **HALT all code generation immediately**
|
||||
2. **Ask the user about design preferences using the standard script below** (must be used verbatim)
|
||||
3. **ONLY begin component development after receiving explicit user feedback on style and effects**
|
||||
|
||||
## Standard Questioning Script (Must Use Verbatim)
|
||||
|
||||
Copy and send this exact message to the user:
|
||||
|
||||
---
|
||||
|
||||
我将为你打造符合 Vue-Bits 极简创意风格的高质量组件。为了避免 AI 模板化效果,给你最贴合需求的设计,请告诉我:
|
||||
|
||||
1. **你喜欢哪种视觉风格?**(例如:极简现代、赛博朋克、复古怀旧、玻璃拟态、新拟态、手绘风等)
|
||||
2. **你想要实现什么具体的动画效果?**(例如:无限循环、悬停触发、滚动触发、渐变、位移、旋转、缩放等)
|
||||
3. **这个组件需要具备哪些交互功能?**(例如:可拖拽、点击事件、响应式、深色模式适配等)
|
||||
4. **有没有特定的配色方案或字体要求?**
|
||||
|
||||
---
|
||||
|
||||
## Component Development Quality Standards
|
||||
|
||||
Only apply these after receiving user feedback.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. Strictly follow vue-bits.dev's "less is more" design philosophy — remove all unnecessary decorative elements
|
||||
2. Animations must be fluid and natural, stable at 60fps, avoid harsh transitions and excessive animation
|
||||
3. Color harmony must meet WCAG accessibility contrast standards
|
||||
4. Components must be highly customizable with clear Props interfaces
|
||||
5. Prefer subtle, refined animations over flashy ones — the animation serves the content, not the other way around
|
||||
6. Dark mode support by default, using Tailwind `dark:` variants or CSS custom properties
|
||||
|
||||
### Code Standards
|
||||
|
||||
1. Use Vue 3 Composition API + `<script setup>` syntax
|
||||
2. Clear component structure: template, script, style separated
|
||||
3. Provide complete TypeScript type definitions
|
||||
4. All configurable items exposed through Props with defaults referencing vue-bits.dev conventions
|
||||
5. Styles use CSS custom properties for theme customization
|
||||
6. Comments concise and clear, only annotate key logic (no verbose docstrings)
|
||||
7. Use `requestAnimationFrame` for animation loops, avoid `setInterval`
|
||||
8. Leverage GPU-accelerated properties: `transform`, `opacity` — avoid animating `width`, `height`, `top`, `left`
|
||||
9. Use SVG for curved/irregular animation paths where appropriate
|
||||
10. Support `prefers-reduced-motion` media query for accessibility
|
||||
|
||||
### Output Requirements
|
||||
|
||||
1. Provide complete Single File Component (SFC) code
|
||||
2. List all available Props with types, default values, and descriptions
|
||||
3. Provide component usage examples (at least 2 variants)
|
||||
4. Explain key implementation principles and performance optimization points
|
||||
5. Provide methods for custom styling (CSS variable overrides, slot usage, etc.)
|
||||
6. Include Tailwind CSS classes where possible, fall back to scoped CSS when needed
|
||||
|
||||
### Common Props Pattern (vue-bits style)
|
||||
|
||||
```typescript
|
||||
interface VueBitsComponentProps {
|
||||
// Visual
|
||||
colorFrom?: string // Gradient start color
|
||||
colorTo?: string // Gradient end color
|
||||
dark?: boolean // Dark mode toggle
|
||||
// Animation
|
||||
duration?: number // Animation duration in seconds
|
||||
delay?: number // Stagger delay in seconds
|
||||
ease?: string // Easing function
|
||||
threshold?: number // IntersectionObserver threshold (0-1)
|
||||
// Behavior
|
||||
paused?: boolean // Pause animation
|
||||
repeat?: number | 'indefinite'
|
||||
// Content
|
||||
text?: string // Text content
|
||||
className?: string // Additional CSS classes
|
||||
}
|
||||
```
|
||||
|
||||
## Forbidden Behaviors
|
||||
|
||||
1. **NEVER** generate component code without first asking user preferences
|
||||
2. **NEVER** generate templated, cookie-cutter AI-style code
|
||||
3. **NEVER** use overly complex or obscure code structures
|
||||
4. **NEVER** add features the user did not explicitly request
|
||||
5. **NEVER** output incomplete or non-runnable code
|
||||
6. **NEVER** use `<marquee>` or other deprecated HTML elements
|
||||
7. **NEVER** hardcode colors — always use CSS custom properties or props
|
||||
8. **NEVER** skip TypeScript type definitions
|
||||
9. **NEVER** animate layout-triggering properties (`width`, `height`, `margin`, etc.)
|
||||
|
||||
## Skill Priority
|
||||
|
||||
This skill has **HIGHER PRIORITY** than all other frontend development related skills. When trigger conditions are met, all rules of this skill **MUST** be enforced.
|
||||
|
||||
## Anti-AI-Flavor Checklist
|
||||
|
||||
Before outputting any component code, verify:
|
||||
|
||||
- [ ] Does this component have a unique visual identity, or could it be from any generic UI library?
|
||||
- [ ] Are the animation curves custom-tuned or just `ease-in-out` defaults?
|
||||
- [ ] Is there at least one surprising/delightful detail that shows craft?
|
||||
- [ ] Would this component feel at home on vue-bits.dev among the showcase?
|
||||
- [ ] Are the default props values thoughtfully chosen, not just `''` or `0`?
|
||||
- [ ] Does the component handle edge cases (empty state, long text, reduced motion)?
|
||||
@@ -0,0 +1,147 @@
|
||||
# Vue-Bits Component Implementation Patterns
|
||||
|
||||
## Curved Loop Text Animation (SVG textPath)
|
||||
|
||||
The core technique: text follows an SVG `<textPath>` along a `<path>`, animated by changing `startOffset`.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<svg viewBox="0 0 1200 300" overflow="visible" class="w-full">
|
||||
<defs>
|
||||
<path id="curve-path" d="M100,150 Q600,-100 1100,150" />
|
||||
</defs>
|
||||
<text :fill="color" :font-size="fontSize" font-weight="600">
|
||||
<textPath href="#curve-path" startOffset="0%">
|
||||
<animate
|
||||
attributeName="startOffset"
|
||||
from="0%" :to="direction === 'right' ? '100%' : '-100%'"
|
||||
:dur="`${speed}s`"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
{{ repeatedText }}
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
```
|
||||
|
||||
Key optimization: SVG `<animate>` runs on the compositor thread, no JS overhead.
|
||||
|
||||
### Alternative: JS-driven for dynamic control
|
||||
|
||||
```typescript
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const offset = ref(0)
|
||||
let rafId: number
|
||||
|
||||
function animate() {
|
||||
offset.value = (offset.value + speed.value / 60) % 100
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => { rafId = requestAnimationFrame(animate) })
|
||||
onUnmounted(() => cancelAnimationFrame(rafId))
|
||||
```
|
||||
|
||||
## Text Scramble Pattern
|
||||
|
||||
Character-by-character randomization using `setInterval` + progressive reveal.
|
||||
|
||||
```typescript
|
||||
const CHARS = '!<>-_\\/[]{}—=+*^?#________'
|
||||
const displayText = ref('')
|
||||
let frame = 0
|
||||
let queue: { from: string; to: string; start: number; end: number }[] = []
|
||||
|
||||
function update() {
|
||||
let output = ''
|
||||
let complete = 0
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
let { from, to, start, end } = queue[i]
|
||||
if (frame >= end) { output += to; complete++; continue }
|
||||
if (frame >= start) {
|
||||
// Random chars during scramble phase
|
||||
const progress = (frame - start) / (end - start)
|
||||
let scrambled = ''
|
||||
for (let j = 0; j < to.length; j++) {
|
||||
if (Math.random() < progress) scrambled += to[j]
|
||||
else scrambled += CHARS[Math.floor(Math.random() * CHARS.length)]
|
||||
}
|
||||
output += scrambled
|
||||
} else {
|
||||
output += from
|
||||
}
|
||||
}
|
||||
displayText.value = output
|
||||
if (complete === queue.length) return // done
|
||||
frame++
|
||||
requestAnimationFrame(() => setTimeout(update, speed.value * 1000))
|
||||
}
|
||||
```
|
||||
|
||||
## Hyperspeed Background Pattern
|
||||
|
||||
Canvas-based tunnel effect using perspective projection of particles.
|
||||
|
||||
```typescript
|
||||
// Core: project 3D points to 2D with perspective divide
|
||||
function project(x: number, y: number, z: number): [number, number] {
|
||||
const scale = focalLength / (focalLength + z)
|
||||
return [x * scale + centerX, y * scale + centerY]
|
||||
}
|
||||
|
||||
// Stars move toward viewer (z decreases), reset when behind camera
|
||||
function updateStars() {
|
||||
for (const star of stars) {
|
||||
star.z -= speed
|
||||
if (star.z < -focalLength) {
|
||||
star.z = maxDepth
|
||||
star.x = (Math.random() - 0.5) * spread
|
||||
star.y = (Math.random() - 0.5) * spread
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optimization: Pre-allocate typed arrays, use `OffscreenCanvas` when available, never read back pixel data.
|
||||
|
||||
## Splash Cursor Pattern
|
||||
|
||||
WebGL fluid simulation (simplified — full implementation uses double FBO ping-pong).
|
||||
|
||||
Key parameters:
|
||||
- `SIM_RESOLUTION` (128): simulation grid size
|
||||
- `DYE_RESOLUTION` (1440): dye texture resolution
|
||||
- `DENSITY_DISSIPATION` (3.5): how fast color fades
|
||||
- `SPLAT_RADIUS` (0.2): mouse click splat radius
|
||||
- `SPLAT_FORCE` (6000): mouse click splat intensity
|
||||
|
||||
The simulation runs a Navier-Stokes solver on GPU via fragment shaders each frame.
|
||||
|
||||
## Performance Optimization Checklist
|
||||
|
||||
1. **RAF over setInterval** — `requestAnimationFrame` syncs with display refresh
|
||||
2. **GPU properties only** — animate `transform`, `opacity`, `filter`; never `width`/`height`/`margin`
|
||||
3. **`will-change` sparingly** — only on elements about to animate, remove when done
|
||||
4. **`contain` property** — `contain: layout style paint` isolates repaint scope
|
||||
5. **IntersectionObserver** — pause off-screen animations (`threshold` prop)
|
||||
6. **`prefers-reduced-motion`** — disable animations when user prefers reduced motion
|
||||
7. **Debounce resize/mousemove** — use `requestAnimationFrame` as natural throttle
|
||||
8. **SVG over Canvas** — for simple path animations (curved text, line draw), SVG is more accessible
|
||||
|
||||
## CSS Custom Properties Convention
|
||||
|
||||
```css
|
||||
.component-root {
|
||||
--vb-color-from: #6366f1;
|
||||
--vb-color-to: #a855f7;
|
||||
--vb-duration: 1.5s;
|
||||
--vb-delay: 0s;
|
||||
--vb-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--vb-font-size: 1rem;
|
||||
--vb-gap: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
Users override these to customize without touching component internals.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.pnpm-store/
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
@@ -25,3 +26,4 @@ dist-ssr
|
||||
/.cursor/
|
||||
/.opencode/
|
||||
/.vscode/
|
||||
/.codex/skills/ui-ux-pro-max/
|
||||
|
||||
10
jsrepo.config.ts
Normal file
10
jsrepo.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'jsrepo';
|
||||
|
||||
export default defineConfig({
|
||||
// configure where stuff comes from here
|
||||
registries: ['https://vue-bits.dev/r'],
|
||||
// configure where stuff goes here
|
||||
paths: {
|
||||
component: 'src/components/vue-bits',
|
||||
},
|
||||
});
|
||||
12
package.json
12
package.json
@@ -6,11 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"preview:static": "node scripts/preview-dist.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"@ant-design-vue/pro-layout": "^3.2.5",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
@@ -18,13 +17,20 @@
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.2",
|
||||
"gsap": "3.13.0",
|
||||
"lenis": "1.3.13",
|
||||
"lucide-vue-next": "^0.507.0",
|
||||
"ogl": "1.0.11",
|
||||
"pinia": "^2.1.7",
|
||||
"swiper": "^11.0.5",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"three": "^0.178.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"jsrepo": "^3.8.1",
|
||||
"sass": "^1.71.0",
|
||||
"vite": "^6.3.1"
|
||||
}
|
||||
|
||||
437
pnpm-lock.yaml
generated
437
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.13)
|
||||
'@fortawesome/fontawesome-free':
|
||||
specifier: ^6.7.2
|
||||
version: 6.7.2
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
|
||||
@@ -23,15 +26,21 @@ importers:
|
||||
ant-design-vue:
|
||||
specifier: ^4.1.1
|
||||
version: 4.2.6(vue@3.5.13)
|
||||
aos:
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
axios:
|
||||
specifier: ^1.6.2
|
||||
version: 1.9.0
|
||||
gsap:
|
||||
specifier: 3.13.0
|
||||
version: 3.13.0
|
||||
lenis:
|
||||
specifier: 1.3.13
|
||||
version: 1.3.13(vue@3.5.13)
|
||||
lucide-vue-next:
|
||||
specifier: ^0.507.0
|
||||
version: 0.507.0(vue@3.5.13)
|
||||
ogl:
|
||||
specifier: 1.0.11
|
||||
version: 1.0.11
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(vue@3.5.13)
|
||||
@@ -41,6 +50,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
three:
|
||||
specifier: ^0.178.0
|
||||
version: 0.178.0
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.13
|
||||
@@ -51,6 +63,9 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.3(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))(vue@3.5.13)
|
||||
jsrepo:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.13)
|
||||
sass:
|
||||
specifier: ^1.71.0
|
||||
version: 1.88.0
|
||||
@@ -107,6 +122,15 @@ packages:
|
||||
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||
|
||||
'@emotion/hash@0.9.2':
|
||||
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
|
||||
|
||||
@@ -263,9 +287,149 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2':
|
||||
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@oxc-parser/binding-android-arm-eabi@0.114.0':
|
||||
resolution: {integrity: sha512-O64coa+5VuqhwQV5t7PcgiY1ubGy35QZSMi+GrXewCRbi7MMzQcuyfv2xg1FV7Kj7SR47+gg/eC9xrOph2Pbcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.114.0':
|
||||
resolution: {integrity: sha512-tfINDNQTAynm7gHWhkCuAnAICf12wD2r3fd2vrMrW7Q9xQcR5pmJN+GUvFjRTeKILdMIPpji+d05PKQtczUxTA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.114.0':
|
||||
resolution: {integrity: sha512-oQpuBInKrb77rgAStzuRkulUOhoqPxTYaQ1vc3CCE2WeVvJyysQF8/f7jtoJUWpEHGKvIq/UELkHyHoETAso6A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.114.0':
|
||||
resolution: {integrity: sha512-nRpuvxB9MTtgChXZ7CdpoVHAfzdo/2+C35UseYbG9LSj2UV9U4Bu7IrHhhDpBZsHT2OaVIMim1joyBNQV0TJqg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.114.0':
|
||||
resolution: {integrity: sha512-zNHFhMwqvwYuXWSyGHMKwobVH4cpcAQSRdUocwT9tbvgsNId00OkQ9sTuXFEthMK6xvAO1prlSiIabkcpLQ/9A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.114.0':
|
||||
resolution: {integrity: sha512-Ew5Fj5iy1YV8pFvdo3oSaOkpgLOySxacZ14XdnY+0eajmsf5VdIGATCISkkacgQWoijlgKTVd8RlsV+wb3v6UQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.114.0':
|
||||
resolution: {integrity: sha512-p3w2kXEHKdAcRKn5T9dVDO39grxFtRLuSu5STVZYEOQ2Z9N/kjsXEsXOumj4kQXCnvF44OUdiURca4NUOm1hmQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.114.0':
|
||||
resolution: {integrity: sha512-qSo2Nnn6XcuSzPOQvidc6HSJMZv09wvtKKi3r8NK4m1zFSCEsZu52I1UMK7qlTVMb0DlPwGzT2QpHdFyad+etQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.114.0':
|
||||
resolution: {integrity: sha512-MiLmga+J7LBwZQDJih7+30PiKYZ0AFpcwz+/H3VllRk3sJQgp2H9pi2UqSwPp4AuW5Mx0Ww5qjI2r++Fvlm8Bg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.114.0':
|
||||
resolution: {integrity: sha512-6zvF/xXydC7P+Gd6DcSipnt/AmSEKtWZhfrKxKwZjA9jkhFG8VoMZsRPe5T6KPuYw3Ieg0afNUnQy5ck6M9X+Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.114.0':
|
||||
resolution: {integrity: sha512-X/IWI+AOP4mQYNjNBxg5vVGlLPPf7VbP7KLnSi1hwj35zL58x82Vi38l++VPj+V5OEaAzUcXRk6gPgpbMkCzcw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.114.0':
|
||||
resolution: {integrity: sha512-HX9Femqubg2VQaDB3MMrGz5hfMlEpnV+MyOuH3ZG8WS41S1oSIFn3/zS1PwnIpd4/a0lssi2995W64qNdFB3fQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.114.0':
|
||||
resolution: {integrity: sha512-K/YYc/BFOD5fKk/vELxoUW0smdC9JpJY/KvnZohi5YMHS0zc45Z/yC3B8GQAWfVFqRFRC4RMy/zX0SgzRHcAOw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.114.0':
|
||||
resolution: {integrity: sha512-uVcvtE8JXtRhrYc/I/ZR8slH4sMxzfeADJFZcS2T5BPpXABKe8RBZbpLhGvbLafmlSGcJ6vsAMKF7spXBe1N3w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.114.0':
|
||||
resolution: {integrity: sha512-lDlzcKpqMMIsErm8Frfz9a7a3qfcphzRwV9oxb27GRMmwmbaVZGBaWRmXXw3HqS2JR5ezF5WQaOqpYtsaxsReA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxc-parser/binding-openharmony-arm64@0.114.0':
|
||||
resolution: {integrity: sha512-8mTgZNPfrWYM3nx0VsDvfFuquNZ3/oZ2Wrvy3xV+plBTncXE7oQJFroKF3AGe4c/OPzoFjpnwhwdTRjG+4J3Cg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.114.0':
|
||||
resolution: {integrity: sha512-GQ5rpymHe7MSZgAGtmxE+v8IzUuM/oJHsiBOurzA6t4ilzVkjUqCLXKckEnVf9r1q9XuTvHmGN5nQnn7uxHRlw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.114.0':
|
||||
resolution: {integrity: sha512-bxPbl48dbczdXam3TrYzl1kwA2lmx1B58icoTeyWb/N2uk1W8MBR7EhWOd0mDuXJ26zLoPtOx8S5DN5AKFMfrw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.114.0':
|
||||
resolution: {integrity: sha512-9JGKhZTwqu5lJwlJfxgoOx3MorcLEv/xynJ1DcUdYYL67OvR1FJElcAXTybePfD1LpIpCxmw0q4tm9exuCiMzg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.114.0':
|
||||
resolution: {integrity: sha512-ATX7ZOUS/FP2vy9ey0FALKFPJDnN3uoEKSCTxH10vTNoREL1D5SIrxfTJcChTWqc4yhed//BrmBCgv+3EvU3VQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxc-project/types@0.114.0':
|
||||
resolution: {integrity: sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -354,6 +518,9 @@ packages:
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
|
||||
cpu: [arm]
|
||||
@@ -562,6 +729,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
@@ -632,9 +802,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3.2.0'
|
||||
|
||||
aos@2.3.4:
|
||||
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
|
||||
|
||||
array-tree-filter@2.1.0:
|
||||
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
|
||||
|
||||
@@ -659,13 +826,14 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
classlist-polyfill@1.2.0:
|
||||
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
compute-scroll-into-view@1.0.20:
|
||||
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||
|
||||
@@ -678,6 +846,9 @@ packages:
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
defu@6.1.7:
|
||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -781,6 +952,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
gsap@3.13.0:
|
||||
resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -819,9 +993,39 @@ packages:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
jsrepo@3.8.1:
|
||||
resolution: {integrity: sha512-ruBJYmmXMN923dMWnVruWmFyHBcD0TzQ+upx6nssiSzRheqTakE/nciEhIpLlO3JcrPsVSXKaXpdt4ExXkVEYA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
lenis@1.3.13:
|
||||
resolution: {integrity: sha512-9FTVlAm2PA82JHnFKWgzJkuxlG7orJneVHqUDhZvVPAQDduEVxlVtXRTNgdhERVR5MUX1iDrRaklBvHbVhxQpg==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
react: '>=17.0.0'
|
||||
vue: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.29.2:
|
||||
resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -893,12 +1097,6 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -908,6 +1106,7 @@ packages:
|
||||
|
||||
lucide-vue-next@0.507.0:
|
||||
resolution: {integrity: sha512-n0AZRmez4xq5vu5ZOfnrhC5wKBpu5tpwDHA6Ue2+sKyJv3USAPxqIerXLbdYbkHRW1tVZ3U9GE1MACsu6X/Z7Q==}
|
||||
deprecated: Package deprecated. Please use @lucide/vue instead.
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -941,9 +1140,16 @@ packages:
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
ogl@1.0.11:
|
||||
resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==}
|
||||
|
||||
omit.js@2.0.2:
|
||||
resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==}
|
||||
|
||||
oxc-parser@0.114.0:
|
||||
resolution: {integrity: sha512-V+tATPyadb6MP51BpK2OMnB27Ln/pDwG7Uk5cAi5jPHRyZwdb72zacuPI6umafcV3VJjh9Jc9WWp0rwEM0AjSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
packrup@0.1.2:
|
||||
resolution: {integrity: sha512-ZcKU7zrr5GlonoS9cxxrb5HVswGnyj6jQvwFBa6p5VFw7G71VAHcUKL5wyZSU/ECtPM/9gacWxy2KFQKt1gMNA==}
|
||||
|
||||
@@ -974,6 +1180,9 @@ packages:
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
quansync@1.0.0:
|
||||
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -1015,6 +1224,9 @@ packages:
|
||||
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
three@0.178.0:
|
||||
resolution: {integrity: sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==}
|
||||
|
||||
throttle-debounce@5.0.2:
|
||||
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
||||
engines: {node: '>=12.22'}
|
||||
@@ -1027,6 +1239,15 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
unconfig-core@7.5.0:
|
||||
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
|
||||
|
||||
unconfig@7.5.0:
|
||||
resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
|
||||
|
||||
unhead@1.11.20:
|
||||
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
|
||||
|
||||
@@ -1150,6 +1371,22 @@ snapshots:
|
||||
|
||||
'@ctrl/tinycolor@3.6.1': {}
|
||||
|
||||
'@emnapi/core@1.11.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.2
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emotion/hash@0.9.2': {}
|
||||
|
||||
'@emotion/unitless@0.8.1': {}
|
||||
@@ -1229,8 +1466,84 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.3':
|
||||
optional: true
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.11.2
|
||||
'@emnapi/runtime': 1.11.2
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-android-arm-eabi@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-android-arm64@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-arm64@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-darwin-x64@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-freebsd-x64@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm-musleabihf@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-gnu@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-arm64-musl@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-ppc64-gnu@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-gnu@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-riscv64-musl@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-s390x-gnu@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-gnu@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-linux-x64-musl@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-openharmony-arm64@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-wasm32-wasi@0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-arm64-msvc@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-ia32-msvc@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-parser/binding-win32-x64-msvc@0.114.0':
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.114.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
@@ -1292,6 +1605,10 @@ snapshots:
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@quansync/fs@1.0.0':
|
||||
dependencies:
|
||||
quansync: 1.0.0
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.40.0':
|
||||
optional: true
|
||||
|
||||
@@ -1422,6 +1739,11 @@ snapshots:
|
||||
tailwindcss: 4.1.4
|
||||
vite: 6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@unhead/dom@1.11.20':
|
||||
@@ -1547,12 +1869,6 @@ snapshots:
|
||||
vue-types: 3.0.2(vue@3.5.13)
|
||||
warning: 4.0.3
|
||||
|
||||
aos@2.3.4:
|
||||
dependencies:
|
||||
classlist-polyfill: 1.2.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.throttle: 4.1.1
|
||||
|
||||
array-tree-filter@2.1.0: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
@@ -1581,12 +1897,12 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
classlist-polyfill@1.2.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
compute-scroll-into-view@1.0.20: {}
|
||||
|
||||
core-js@3.42.0: {}
|
||||
@@ -1595,6 +1911,8 @@ snapshots:
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
@@ -1709,6 +2027,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
gsap@3.13.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
@@ -1738,8 +2058,25 @@ snapshots:
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsrepo@3.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.13):
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
oxc-parser: 0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
|
||||
unconfig: 7.5.0
|
||||
optionalDependencies:
|
||||
vue: 3.5.13
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
lenis@1.3.13(vue@3.5.13):
|
||||
optionalDependencies:
|
||||
vue: 3.5.13
|
||||
|
||||
lightningcss-darwin-arm64@1.29.2:
|
||||
optional: true
|
||||
|
||||
@@ -1787,10 +2124,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -1826,8 +2159,38 @@ snapshots:
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
ogl@1.0.11: {}
|
||||
|
||||
omit.js@2.0.2: {}
|
||||
|
||||
oxc-parser@0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.114.0
|
||||
optionalDependencies:
|
||||
'@oxc-parser/binding-android-arm-eabi': 0.114.0
|
||||
'@oxc-parser/binding-android-arm64': 0.114.0
|
||||
'@oxc-parser/binding-darwin-arm64': 0.114.0
|
||||
'@oxc-parser/binding-darwin-x64': 0.114.0
|
||||
'@oxc-parser/binding-freebsd-x64': 0.114.0
|
||||
'@oxc-parser/binding-linux-arm-gnueabihf': 0.114.0
|
||||
'@oxc-parser/binding-linux-arm-musleabihf': 0.114.0
|
||||
'@oxc-parser/binding-linux-arm64-gnu': 0.114.0
|
||||
'@oxc-parser/binding-linux-arm64-musl': 0.114.0
|
||||
'@oxc-parser/binding-linux-ppc64-gnu': 0.114.0
|
||||
'@oxc-parser/binding-linux-riscv64-gnu': 0.114.0
|
||||
'@oxc-parser/binding-linux-riscv64-musl': 0.114.0
|
||||
'@oxc-parser/binding-linux-s390x-gnu': 0.114.0
|
||||
'@oxc-parser/binding-linux-x64-gnu': 0.114.0
|
||||
'@oxc-parser/binding-linux-x64-musl': 0.114.0
|
||||
'@oxc-parser/binding-openharmony-arm64': 0.114.0
|
||||
'@oxc-parser/binding-wasm32-wasi': 0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
|
||||
'@oxc-parser/binding-win32-arm64-msvc': 0.114.0
|
||||
'@oxc-parser/binding-win32-ia32-msvc': 0.114.0
|
||||
'@oxc-parser/binding-win32-x64-msvc': 0.114.0
|
||||
transitivePeerDependencies:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
packrup@0.1.2: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -1853,6 +2216,8 @@ snapshots:
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
quansync@1.0.0: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
@@ -1907,6 +2272,8 @@ snapshots:
|
||||
|
||||
tapable@2.2.1: {}
|
||||
|
||||
three@0.178.0: {}
|
||||
|
||||
throttle-debounce@5.0.2: {}
|
||||
|
||||
tinyglobby@0.2.13:
|
||||
@@ -1919,6 +2286,22 @@ snapshots:
|
||||
is-number: 7.0.0
|
||||
optional: true
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
unconfig-core@7.5.0:
|
||||
dependencies:
|
||||
'@quansync/fs': 1.0.0
|
||||
quansync: 1.0.0
|
||||
|
||||
unconfig@7.5.0:
|
||||
dependencies:
|
||||
'@quansync/fs': 1.0.0
|
||||
defu: 6.1.7
|
||||
jiti: 2.7.0
|
||||
quansync: 1.0.0
|
||||
unconfig-core: 7.5.0
|
||||
|
||||
unhead@1.11.20:
|
||||
dependencies:
|
||||
'@unhead/dom': 1.11.20
|
||||
|
||||
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
56
scripts/preview-dist.cjs
Normal file
56
scripts/preview-dist.cjs
Normal file
@@ -0,0 +1,56 @@
|
||||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const path = require('path')
|
||||
|
||||
const port = Number(process.env.PORT || 8888)
|
||||
const host = process.env.HOST || '127.0.0.1'
|
||||
const root = path.resolve(__dirname, '..', 'dist')
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css;charset=utf-8',
|
||||
'.html': 'text/html;charset=utf-8',
|
||||
'.js': 'text/javascript;charset=utf-8',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ttf': 'font/ttf',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
}
|
||||
|
||||
function sendFile(file, response) {
|
||||
fs.readFile(file, (error, data) => {
|
||||
if (error) {
|
||||
response.writeHead(404)
|
||||
response.end('Not found')
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': contentTypes[path.extname(file)] || 'application/octet-stream',
|
||||
})
|
||||
response.end(data)
|
||||
})
|
||||
}
|
||||
|
||||
http.createServer((request, response) => {
|
||||
const cleanUrl = decodeURIComponent(request.url.split('?')[0])
|
||||
let file = path.join(root, cleanUrl === '/' ? 'index.html' : cleanUrl)
|
||||
|
||||
if (!file.startsWith(root)) {
|
||||
response.writeHead(403)
|
||||
response.end('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
fs.stat(file, (error, stats) => {
|
||||
if (error || !stats.isFile()) {
|
||||
file = path.join(root, 'index.html')
|
||||
}
|
||||
sendFile(file, response)
|
||||
})
|
||||
}).listen(port, host, () => {
|
||||
console.log(`萧康云医 preview http://${host}:${port}/`)
|
||||
})
|
||||
93
src/assets/MapleMono-NF-CN/LICENSE.txt
Normal file
93
src/assets/MapleMono-NF-CN/LICENSE.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Maple Mono Project Authors (https://github.com/subframe7536/maple-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Bold.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-BoldItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraBold.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraBoldItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraLight.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraLightItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Italic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Italic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Light.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Light.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-LightItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-LightItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Medium.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Medium.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-MediumItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Regular.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-SemiBold.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-SemiBoldItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Thin.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-Thin.ttf
Normal file
Binary file not shown.
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ThinItalic.ttf
Normal file
BIN
src/assets/MapleMono-NF-CN/MapleMono-NF-CN-ThinItalic.ttf
Normal file
Binary file not shown.
88
src/assets/MapleMono-NF-CN/config.json
Normal file
88
src/assets/MapleMono-NF-CN/config.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"version": "v7.9",
|
||||
"family_name": "Maple Mono",
|
||||
"weight_mapping": {
|
||||
"thin": 100,
|
||||
"extralight": 200,
|
||||
"light": 300,
|
||||
"regular": 400,
|
||||
"medium": 500,
|
||||
"semibold": 600,
|
||||
"bold": 700,
|
||||
"extrabold": 800
|
||||
},
|
||||
"line_height": 1.0,
|
||||
"use_hinted": true,
|
||||
"ligature": true,
|
||||
"feature_freeze": {
|
||||
"cv01": "ignore",
|
||||
"cv02": "ignore",
|
||||
"cv03": "ignore",
|
||||
"cv04": "ignore",
|
||||
"cv31": "ignore",
|
||||
"cv32": "ignore",
|
||||
"cv33": "ignore",
|
||||
"cv34": "ignore",
|
||||
"cv35": "ignore",
|
||||
"cv36": "ignore",
|
||||
"cv37": "ignore",
|
||||
"cv96": "ignore",
|
||||
"cv97": "ignore",
|
||||
"cv98": "ignore",
|
||||
"cv99": "ignore",
|
||||
"ss01": "ignore",
|
||||
"ss02": "ignore",
|
||||
"ss03": "ignore",
|
||||
"ss04": "ignore",
|
||||
"ss05": "ignore",
|
||||
"ss06": "ignore",
|
||||
"ss07": "ignore",
|
||||
"ss08": "ignore",
|
||||
"zero": "ignore",
|
||||
"cv05": "ignore",
|
||||
"cv06": "ignore",
|
||||
"cv07": "ignore",
|
||||
"cv08": "ignore",
|
||||
"cv09": "ignore",
|
||||
"cv10": "ignore",
|
||||
"cv11": "ignore",
|
||||
"cv38": "ignore",
|
||||
"cv39": "ignore",
|
||||
"cv40": "ignore",
|
||||
"cv41": "ignore",
|
||||
"cv42": "ignore",
|
||||
"cv43": "ignore",
|
||||
"cv44": "ignore",
|
||||
"cv61": "ignore",
|
||||
"cv62": "ignore",
|
||||
"cv63": "ignore",
|
||||
"cv64": "ignore",
|
||||
"cv65": "ignore",
|
||||
"cv66": "ignore",
|
||||
"ss09": "ignore",
|
||||
"ss10": "ignore",
|
||||
"ss11": "ignore"
|
||||
},
|
||||
"nerd_font": {
|
||||
"version": "3.4.0",
|
||||
"mono": false,
|
||||
"propo": false,
|
||||
"use_font_patcher": false,
|
||||
"glyphs": [
|
||||
"--complete"
|
||||
],
|
||||
"extra_args": []
|
||||
},
|
||||
"cn": {
|
||||
"with_nerd_font": false,
|
||||
"fix_meta_table": true,
|
||||
"clean_cache": false,
|
||||
"narrow": false,
|
||||
"use_hinted": false,
|
||||
"use_static_base_font": true,
|
||||
"scale_factor": [
|
||||
1.0,
|
||||
1.0
|
||||
]
|
||||
}
|
||||
}
|
||||
269
src/components/SiteHeader.vue
Normal file
269
src/components/SiteHeader.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ChevronDown, Menu, Monitor, Moon, Search, Stethoscope, Sun, User, X } from 'lucide-vue-next'
|
||||
import { siteConfig } from '@/site.config'
|
||||
|
||||
const route = useRoute()
|
||||
const themeMode = ref('system')
|
||||
const resolvedTheme = ref('light')
|
||||
const isThemeOpen = ref(false)
|
||||
const themeButtonRef = ref(null)
|
||||
const themePopoverStyle = ref({})
|
||||
const isMobileOpen = ref(false)
|
||||
const openDesktopMenu = ref(null)
|
||||
const openMobileGroups = ref(['home'])
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'system', label: '跟随系统', icon: Monitor },
|
||||
{ value: 'light', label: '亮色', icon: Sun },
|
||||
{ value: 'dark', label: '暗色', icon: Moon }
|
||||
]
|
||||
|
||||
const menuGroups = [
|
||||
{ key: 'home', label: '首页', to: '/', links: siteConfig.navigation.secondary.home },
|
||||
{ key: 'product', label: '产品', to: '/product', links: siteConfig.navigation.secondary.product }
|
||||
]
|
||||
|
||||
const activeTheme = computed(() => themeOptions.find((item) => item.value === themeMode.value) || themeOptions[0])
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const isProduct = computed(() => route.path.startsWith('/product'))
|
||||
const navTone = computed(() => {
|
||||
if (isHome.value) return 'dark'
|
||||
return resolvedTheme.value === 'dark' ? 'dark' : 'light'
|
||||
})
|
||||
|
||||
let mediaQuery
|
||||
|
||||
function syncThemePopoverPosition() {
|
||||
if (!themeButtonRef.value) return
|
||||
|
||||
const rect = themeButtonRef.value.getBoundingClientRect()
|
||||
const popoverWidth = 168
|
||||
const viewportGap = 12
|
||||
const top = rect.bottom + viewportGap
|
||||
const left = Math.min(
|
||||
window.innerWidth - popoverWidth - viewportGap,
|
||||
Math.max(viewportGap, rect.right - popoverWidth)
|
||||
)
|
||||
const arrowX = Math.max(20, Math.min(popoverWidth - 20, rect.left + rect.width / 2 - left))
|
||||
|
||||
themePopoverStyle.value = {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
'--theme-arrow-x': `${arrowX}px`
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThemePopover() {
|
||||
isThemeOpen.value = !isThemeOpen.value
|
||||
if (isThemeOpen.value) nextTick(syncThemePopoverPosition)
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const nextTheme = themeMode.value === 'system'
|
||||
? (mediaQuery?.matches ? 'dark' : 'light')
|
||||
: themeMode.value
|
||||
|
||||
resolvedTheme.value = nextTheme
|
||||
document.documentElement.dataset.theme = nextTheme
|
||||
document.documentElement.dataset.themeMode = themeMode.value
|
||||
document.documentElement.style.colorScheme = nextTheme
|
||||
}
|
||||
|
||||
function setTheme(value) {
|
||||
themeMode.value = value
|
||||
localStorage.setItem('xiaokang-theme-mode', value)
|
||||
applyTheme()
|
||||
isThemeOpen.value = false
|
||||
}
|
||||
|
||||
function isActive(path) {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function toggleMobileGroup(key) {
|
||||
openMobileGroups.value = openMobileGroups.value.includes(key)
|
||||
? openMobileGroups.value.filter((item) => item !== key)
|
||||
: [...openMobileGroups.value, key]
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
isMobileOpen.value = false
|
||||
isThemeOpen.value = false
|
||||
openDesktopMenu.value = null
|
||||
})
|
||||
|
||||
watch(isThemeOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
nextTick(syncThemePopoverPosition)
|
||||
window.addEventListener('resize', syncThemePopoverPosition)
|
||||
window.addEventListener('scroll', syncThemePopoverPosition, true)
|
||||
} else {
|
||||
window.removeEventListener('resize', syncThemePopoverPosition)
|
||||
window.removeEventListener('scroll', syncThemePopoverPosition, true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
themeMode.value = localStorage.getItem('xiaokang-theme-mode') || 'system'
|
||||
applyTheme()
|
||||
mediaQuery.addEventListener('change', applyTheme)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mediaQuery?.removeEventListener('change', applyTheme)
|
||||
window.removeEventListener('resize', syncThemePopoverPosition)
|
||||
window.removeEventListener('scroll', syncThemePopoverPosition, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="site-header"
|
||||
:class="[
|
||||
`site-header-tone-${navTone}`,
|
||||
{
|
||||
'site-header-home': isHome,
|
||||
'site-header-solid': !isHome,
|
||||
'site-header-product': isProduct
|
||||
}
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="site-nav animate-blur-fade-up"
|
||||
:class="[
|
||||
`site-nav-tone-${navTone}`,
|
||||
{
|
||||
'site-nav-overlay': isHome,
|
||||
'site-nav-solid': !isHome,
|
||||
'site-nav-product': isProduct
|
||||
}
|
||||
]"
|
||||
style="animation-delay: 0ms"
|
||||
>
|
||||
<RouterLink to="/" class="site-logo" aria-label="萧康云医首页">
|
||||
<span class="site-logo-mark">
|
||||
<Stethoscope :size="20" stroke-width="2.4" aria-hidden="true" />
|
||||
</span>
|
||||
<span>{{ siteConfig.brand.name }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="desktop-primary-nav" aria-label="主导航">
|
||||
<div
|
||||
v-for="(group, index) in menuGroups"
|
||||
:key="group.key"
|
||||
class="primary-nav-item animate-blur-fade-up"
|
||||
:style="{ animationDelay: `${100 + index * 80}ms` }"
|
||||
@mouseenter="openDesktopMenu = group.key"
|
||||
@mouseleave="openDesktopMenu = null"
|
||||
>
|
||||
<RouterLink
|
||||
:to="group.to"
|
||||
class="primary-nav-link"
|
||||
:class="{ 'primary-nav-link-active': isActive(group.to) }"
|
||||
>
|
||||
{{ group.label }}
|
||||
<ChevronDown :size="15" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
|
||||
<Transition name="bubble">
|
||||
<div v-if="openDesktopMenu === group.key" class="desktop-submenu liquid-glass">
|
||||
<RouterLink
|
||||
v-for="link in group.links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="submenu-link"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="site-actions">
|
||||
<button type="button" class="action-pill liquid-glass nav-action animate-blur-fade-up" style="animation-delay: 300ms">
|
||||
<Search :size="18" aria-hidden="true" />
|
||||
<span>搜索</span>
|
||||
</button>
|
||||
|
||||
<div class="theme-control animate-blur-fade-up" style="animation-delay: 360ms">
|
||||
<button
|
||||
ref="themeButtonRef"
|
||||
type="button"
|
||||
class="icon-pill liquid-glass"
|
||||
:aria-expanded="isThemeOpen"
|
||||
aria-haspopup="menu"
|
||||
aria-label="切换主题"
|
||||
@click="toggleThemePopover"
|
||||
>
|
||||
<component :is="activeTheme.icon" :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/product#contact" class="icon-pill liquid-glass nav-action animate-blur-fade-up" style="animation-delay: 420ms" aria-label="联系顾问">
|
||||
<User :size="18" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mobile-menu-button icon-pill liquid-glass animate-blur-fade-up"
|
||||
style="animation-delay: 420ms"
|
||||
:aria-expanded="isMobileOpen"
|
||||
aria-label="打开菜单"
|
||||
@click="isMobileOpen = !isMobileOpen"
|
||||
>
|
||||
<Menu :class="{ 'menu-icon-hidden': isMobileOpen }" :size="20" aria-hidden="true" />
|
||||
<X :class="{ 'menu-icon-visible': isMobileOpen }" :size="20" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="mobile-menu">
|
||||
<div v-if="isMobileOpen" class="mobile-submenu liquid-glass">
|
||||
<div v-for="group in menuGroups" :key="group.key" class="mobile-menu-group">
|
||||
<button type="button" class="mobile-group-trigger" @click="toggleMobileGroup(group.key)">
|
||||
<span>{{ group.label }}</span>
|
||||
<ChevronDown :class="{ 'rotate-180': openMobileGroups.includes(group.key) }" :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="openMobileGroups.includes(group.key)" class="mobile-group-links">
|
||||
<RouterLink :to="group.to" class="mobile-sub-link">进入{{ group.label }}</RouterLink>
|
||||
<RouterLink v-for="link in group.links" :key="link.to" :to="link.to" class="mobile-sub-link">
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</header>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="bubble">
|
||||
<div
|
||||
v-if="isThemeOpen"
|
||||
class="theme-popover theme-popover-floating liquid-glass"
|
||||
:class="`theme-popover-tone-${navTone}`"
|
||||
:style="themePopoverStyle"
|
||||
role="menu"
|
||||
aria-label="主题模式"
|
||||
>
|
||||
<button
|
||||
v-for="item in themeOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="theme-option"
|
||||
:class="{ 'theme-option-active': item.value === themeMode }"
|
||||
role="menuitemradio"
|
||||
:aria-checked="item.value === themeMode"
|
||||
@click="setTheme(item.value)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
98
src/components/ThemeBackdrop.vue
Normal file
98
src/components/ThemeBackdrop.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import FloatingLines from '@/components/vue-bits/FloatingLines.vue'
|
||||
import Galaxy from '@/components/vue-bits/Galaxy.vue'
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
const reduceMotion = ref(false)
|
||||
const isVisible = ref(false)
|
||||
const backdropRef = ref<HTMLElement | null>(null)
|
||||
const shouldRenderWebgl = computed(() => isVisible.value && !reduceMotion.value)
|
||||
const canUsePointerInteraction = computed(() => shouldRenderWebgl.value && !reduceMotion.value)
|
||||
|
||||
let observer: MutationObserver | null = null
|
||||
let motionQuery: MediaQueryList | null = null
|
||||
let visibilityObserver: IntersectionObserver | null = null
|
||||
|
||||
function readTheme() {
|
||||
theme.value = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function readMotionPreference() {
|
||||
reduceMotion.value = Boolean(motionQuery?.matches)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
readTheme()
|
||||
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
readMotionPreference()
|
||||
motionQuery.addEventListener('change', readMotionPreference)
|
||||
|
||||
observer = new MutationObserver(readTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
})
|
||||
|
||||
if ('IntersectionObserver' in window && backdropRef.value) {
|
||||
visibilityObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisible.value = entry.isIntersecting
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
visibilityObserver.observe(backdropRef.value)
|
||||
} else {
|
||||
isVisible.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
visibilityObserver?.disconnect()
|
||||
motionQuery?.removeEventListener('change', readMotionPreference)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="backdropRef" class="theme-backdrop">
|
||||
<div v-if="shouldRenderWebgl" class="theme-backdrop-canvas" aria-hidden="true">
|
||||
<FloatingLines
|
||||
v-if="theme === 'light'"
|
||||
:lines-gradient="['#eef3f5', '#c7d0d5', '#d7e2ea']"
|
||||
:line-count="[4, 3, 2]"
|
||||
:line-distance="[12, 18, 24]"
|
||||
:animation-speed="0.22"
|
||||
:interactive="canUsePointerInteraction"
|
||||
:mouse-damping="0.045"
|
||||
:parallax="canUsePointerInteraction"
|
||||
:parallax-strength="0.08"
|
||||
pointer-target="window"
|
||||
mix-blend-mode="multiply"
|
||||
/>
|
||||
<Galaxy
|
||||
v-else
|
||||
:density="0.32"
|
||||
:glow-intensity="0.1"
|
||||
:hue-shift="205"
|
||||
:mouse-interaction="canUsePointerInteraction"
|
||||
:mouse-repulsion="false"
|
||||
:saturation="0.02"
|
||||
:speed="0.26"
|
||||
:star-speed="0.14"
|
||||
:twinkle-intensity="0.055"
|
||||
:rotation-speed="0.008"
|
||||
pointer-target="window"
|
||||
transparent
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-backdrop-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
139
src/components/vue-bits/AnimatedContent.vue
Normal file
139
src/components/vue-bits/AnimatedContent.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface AnimatedContentProps {
|
||||
distance?: number;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
reverse?: boolean;
|
||||
duration?: number;
|
||||
ease?: string | ((progress: number) => number);
|
||||
initialOpacity?: number;
|
||||
animateOpacity?: boolean;
|
||||
scale?: number;
|
||||
threshold?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AnimatedContentProps>(), {
|
||||
distance: 100,
|
||||
direction: 'vertical',
|
||||
reverse: false,
|
||||
duration: 0.8,
|
||||
ease: 'power3.out',
|
||||
initialOpacity: 0,
|
||||
animateOpacity: true,
|
||||
scale: 1,
|
||||
threshold: 0.1,
|
||||
delay: 0,
|
||||
className: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
}>();
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y';
|
||||
const offset = props.reverse ? -props.distance : props.distance;
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1
|
||||
});
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
delay: props.delay,
|
||||
onComplete: () => emit('complete'),
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.distance,
|
||||
props.direction,
|
||||
props.reverse,
|
||||
props.duration,
|
||||
props.ease,
|
||||
props.initialOpacity,
|
||||
props.animateOpacity,
|
||||
props.scale,
|
||||
props.threshold,
|
||||
props.delay
|
||||
],
|
||||
() => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
ScrollTrigger.getAll().forEach(t => t.kill());
|
||||
gsap.killTweensOf(el);
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y';
|
||||
const offset = props.reverse ? -props.distance : props.distance;
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1
|
||||
});
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
delay: props.delay,
|
||||
onComplete: () => emit('complete'),
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true
|
||||
}
|
||||
});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = containerRef.value;
|
||||
if (el) {
|
||||
ScrollTrigger.getAll().forEach(t => t.kill());
|
||||
gsap.killTweensOf(el);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="`animated-content ${props.className}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* GSAP will handle all transforms and opacity */
|
||||
</style>
|
||||
360
src/components/vue-bits/DecryptedText.vue
Normal file
360
src/components/vue-bits/DecryptedText.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<span ref="containerRef" :class="`inline-block whitespace-pre-wrap ${parentClassName}`" v-bind="animateListeners">
|
||||
<span class="sr-only">{{ displayText }}</span>
|
||||
|
||||
<span aria-hidden="true">
|
||||
<span
|
||||
v-for="(char, index) in displayText.split('')"
|
||||
:key="index"
|
||||
:class="isRevealedOrDone(index) ? className : encryptedClassName"
|
||||
>
|
||||
{{ char }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
type RevealDirection = 'start' | 'end' | 'center';
|
||||
type AnimateOn = 'view' | 'hover' | 'inViewHover' | 'click';
|
||||
type ClickMode = 'once' | 'toggle';
|
||||
type Direction = 'forward' | 'reverse';
|
||||
|
||||
interface DecryptedTextProps {
|
||||
text: string;
|
||||
speed?: number;
|
||||
maxIterations?: number;
|
||||
sequential?: boolean;
|
||||
revealDirection?: RevealDirection;
|
||||
useOriginalCharsOnly?: boolean;
|
||||
characters?: string;
|
||||
className?: string;
|
||||
encryptedClassName?: string;
|
||||
parentClassName?: string;
|
||||
animateOn?: AnimateOn;
|
||||
clickMode?: ClickMode;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DecryptedTextProps>(), {
|
||||
speed: 50,
|
||||
maxIterations: 10,
|
||||
sequential: false,
|
||||
revealDirection: 'start',
|
||||
useOriginalCharsOnly: false,
|
||||
characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
|
||||
className: '',
|
||||
encryptedClassName: '',
|
||||
parentClassName: '',
|
||||
animateOn: 'hover',
|
||||
clickMode: 'once'
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLSpanElement | null>(null);
|
||||
|
||||
const displayText = ref<string>(props.text);
|
||||
const isAnimating = ref<boolean>(false);
|
||||
const revealedIndices = ref<Set<number>>(new Set());
|
||||
const hasAnimated = ref<boolean>(false);
|
||||
const isDecrypted = ref<boolean>(props.animateOn !== 'click');
|
||||
const direction = ref<Direction>('forward');
|
||||
|
||||
const orderRef = ref<number[]>([]);
|
||||
const pointerRef = ref<number>(0);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const availableChars = computed<string[]>(() => {
|
||||
return props.useOriginalCharsOnly
|
||||
? Array.from(new Set(props.text.split(''))).filter(char => char !== ' ')
|
||||
: props.characters.split('');
|
||||
});
|
||||
|
||||
function shuffleText(originalText: string, currentRevealed: Set<number>): string {
|
||||
return originalText
|
||||
.split('')
|
||||
.map((char, i) => {
|
||||
if (char === ' ') return ' ';
|
||||
if (currentRevealed.has(i)) return originalText[i];
|
||||
return availableChars.value[Math.floor(Math.random() * availableChars.value.length)];
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function computeOrder(len: number): number[] {
|
||||
const order: number[] = [];
|
||||
if (len <= 0) return order;
|
||||
if (props.revealDirection === 'start') {
|
||||
for (let i = 0; i < len; i++) order.push(i);
|
||||
return order;
|
||||
}
|
||||
if (props.revealDirection === 'end') {
|
||||
for (let i = len - 1; i >= 0; i--) order.push(i);
|
||||
return order;
|
||||
}
|
||||
const middle = Math.floor(len / 2);
|
||||
let offset = 0;
|
||||
while (order.length < len) {
|
||||
if (offset % 2 === 0) {
|
||||
const idx = middle + offset / 2;
|
||||
if (idx >= 0 && idx < len) order.push(idx);
|
||||
} else {
|
||||
const idx = middle - Math.ceil(offset / 2);
|
||||
if (idx >= 0 && idx < len) order.push(idx);
|
||||
}
|
||||
offset++;
|
||||
}
|
||||
return order.slice(0, len);
|
||||
}
|
||||
|
||||
function fillAllIndices(): Set<number> {
|
||||
const s = new Set<number>();
|
||||
for (let i = 0; i < props.text.length; i++) s.add(i);
|
||||
return s;
|
||||
}
|
||||
|
||||
function removeRandomIndices(set: Set<number>, count: number): Set<number> {
|
||||
const arr = Array.from(set);
|
||||
for (let i = 0; i < count && arr.length > 0; i++) {
|
||||
const idx = Math.floor(Math.random() * arr.length);
|
||||
arr.splice(idx, 1);
|
||||
}
|
||||
return new Set(arr);
|
||||
}
|
||||
|
||||
function encryptInstantly() {
|
||||
const emptySet = new Set<number>();
|
||||
revealedIndices.value = emptySet;
|
||||
displayText.value = shuffleText(props.text, emptySet);
|
||||
isDecrypted.value = false;
|
||||
}
|
||||
|
||||
function triggerDecrypt() {
|
||||
if (props.sequential) {
|
||||
orderRef.value = computeOrder(props.text.length);
|
||||
pointerRef.value = 0;
|
||||
revealedIndices.value = new Set();
|
||||
} else {
|
||||
revealedIndices.value = new Set();
|
||||
}
|
||||
direction.value = 'forward';
|
||||
isAnimating.value = true;
|
||||
}
|
||||
|
||||
function triggerReverse() {
|
||||
if (props.sequential) {
|
||||
orderRef.value = computeOrder(props.text.length).slice().reverse();
|
||||
pointerRef.value = 0;
|
||||
const full = fillAllIndices();
|
||||
revealedIndices.value = full;
|
||||
displayText.value = shuffleText(props.text, full);
|
||||
} else {
|
||||
const full = fillAllIndices();
|
||||
revealedIndices.value = full;
|
||||
displayText.value = shuffleText(props.text, full);
|
||||
}
|
||||
direction.value = 'reverse';
|
||||
isAnimating.value = true;
|
||||
}
|
||||
|
||||
function getNextIndex(revealedSet: Set<number>): number {
|
||||
const textLength = props.text.length;
|
||||
switch (props.revealDirection) {
|
||||
case 'start':
|
||||
return revealedSet.size;
|
||||
case 'end':
|
||||
return textLength - 1 - revealedSet.size;
|
||||
case 'center': {
|
||||
const middle = Math.floor(textLength / 2);
|
||||
const offset = Math.floor(revealedSet.size / 2);
|
||||
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
|
||||
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (!revealedSet.has(i)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
return revealedSet.size;
|
||||
}
|
||||
}
|
||||
|
||||
function stopInterval() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startInterval() {
|
||||
stopInterval();
|
||||
let currentIteration = 0;
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (props.sequential) {
|
||||
if (direction.value === 'forward') {
|
||||
if (revealedIndices.value.size < props.text.length) {
|
||||
const nextIndex = getNextIndex(revealedIndices.value);
|
||||
const newRevealed = new Set(revealedIndices.value);
|
||||
newRevealed.add(nextIndex);
|
||||
revealedIndices.value = newRevealed;
|
||||
displayText.value = shuffleText(props.text, newRevealed);
|
||||
} else {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
isDecrypted.value = true;
|
||||
}
|
||||
} else {
|
||||
if (pointerRef.value < orderRef.value.length) {
|
||||
const idxToRemove = orderRef.value[pointerRef.value++];
|
||||
const newRevealed = new Set(revealedIndices.value);
|
||||
newRevealed.delete(idxToRemove);
|
||||
revealedIndices.value = newRevealed;
|
||||
displayText.value = shuffleText(props.text, newRevealed);
|
||||
if (newRevealed.size === 0) {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
isDecrypted.value = false;
|
||||
}
|
||||
} else {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
isDecrypted.value = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (direction.value === 'forward') {
|
||||
displayText.value = shuffleText(props.text, revealedIndices.value);
|
||||
currentIteration++;
|
||||
if (currentIteration >= props.maxIterations) {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
displayText.value = props.text;
|
||||
isDecrypted.value = true;
|
||||
}
|
||||
} else {
|
||||
let currentSet = revealedIndices.value;
|
||||
if (currentSet.size === 0) currentSet = fillAllIndices();
|
||||
const removeCount = Math.max(1, Math.ceil(props.text.length / Math.max(1, props.maxIterations)));
|
||||
const nextSet = removeRandomIndices(currentSet, removeCount);
|
||||
revealedIndices.value = nextSet;
|
||||
displayText.value = shuffleText(props.text, nextSet);
|
||||
currentIteration++;
|
||||
if (nextSet.size === 0 || currentIteration >= props.maxIterations) {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
isDecrypted.value = false;
|
||||
displayText.value = shuffleText(props.text, new Set());
|
||||
revealedIndices.value = new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, props.speed);
|
||||
}
|
||||
|
||||
watch(isAnimating, val => {
|
||||
if (val) startInterval();
|
||||
else stopInterval();
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
if (props.animateOn !== 'click') return;
|
||||
if (props.clickMode === 'once') {
|
||||
if (isDecrypted.value) return;
|
||||
direction.value = 'forward';
|
||||
triggerDecrypt();
|
||||
}
|
||||
if (props.clickMode === 'toggle') {
|
||||
if (isDecrypted.value) {
|
||||
triggerReverse();
|
||||
} else {
|
||||
direction.value = 'forward';
|
||||
triggerDecrypt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function triggerHoverDecrypt() {
|
||||
if (isAnimating.value) return;
|
||||
revealedIndices.value = new Set();
|
||||
isDecrypted.value = false;
|
||||
displayText.value = props.text;
|
||||
direction.value = 'forward';
|
||||
isAnimating.value = true;
|
||||
}
|
||||
|
||||
function resetToPlainText() {
|
||||
stopInterval();
|
||||
isAnimating.value = false;
|
||||
revealedIndices.value = new Set();
|
||||
displayText.value = props.text;
|
||||
isDecrypted.value = true;
|
||||
direction.value = 'forward';
|
||||
}
|
||||
|
||||
const animateListeners = computed(() => {
|
||||
if (props.animateOn === 'hover' || props.animateOn === 'inViewHover') {
|
||||
return {
|
||||
onMouseenter: triggerHoverDecrypt,
|
||||
onMouseleave: resetToPlainText
|
||||
};
|
||||
}
|
||||
if (props.animateOn === 'click') {
|
||||
return { onClick: handleClick };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
function isRevealedOrDone(index: number): boolean {
|
||||
return revealedIndices.value.has(index) || (!isAnimating.value && isDecrypted.value);
|
||||
}
|
||||
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (props.animateOn === 'view' || props.animateOn === 'inViewHover') {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !hasAnimated.value) {
|
||||
triggerDecrypt();
|
||||
hasAnimated.value = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: null, rootMargin: '0px', threshold: 0.1 }
|
||||
);
|
||||
if (containerRef.value) intersectionObserver.observe(containerRef.value);
|
||||
}
|
||||
|
||||
if (props.animateOn === 'click') {
|
||||
encryptInstantly();
|
||||
} else {
|
||||
displayText.value = props.text;
|
||||
isDecrypted.value = true;
|
||||
}
|
||||
revealedIndices.value = new Set();
|
||||
direction.value = 'forward';
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopInterval();
|
||||
intersectionObserver?.disconnect();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.animateOn, props.text] as const,
|
||||
([animateOn, text]) => {
|
||||
if (animateOn === 'click') {
|
||||
encryptInstantly();
|
||||
} else {
|
||||
displayText.value = text;
|
||||
isDecrypted.value = true;
|
||||
}
|
||||
revealedIndices.value = new Set();
|
||||
direction.value = 'forward';
|
||||
}
|
||||
);
|
||||
</script>
|
||||
537
src/components/vue-bits/FloatingLines.vue
Normal file
537
src/components/vue-bits/FloatingLines.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Clock,
|
||||
Mesh,
|
||||
OrthographicCamera,
|
||||
PlaneGeometry,
|
||||
Scene,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderer
|
||||
} from 'three';
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, type CSSProperties } from 'vue';
|
||||
|
||||
const vertexShader = `
|
||||
precision highp float;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float animationSpeed;
|
||||
|
||||
uniform bool enableTop;
|
||||
uniform bool enableMiddle;
|
||||
uniform bool enableBottom;
|
||||
|
||||
uniform int topLineCount;
|
||||
uniform int middleLineCount;
|
||||
uniform int bottomLineCount;
|
||||
|
||||
uniform float topLineDistance;
|
||||
uniform float middleLineDistance;
|
||||
uniform float bottomLineDistance;
|
||||
|
||||
uniform vec3 topWavePosition;
|
||||
uniform vec3 middleWavePosition;
|
||||
uniform vec3 bottomWavePosition;
|
||||
|
||||
uniform vec2 iMouse;
|
||||
uniform bool interactive;
|
||||
uniform float bendRadius;
|
||||
uniform float bendStrength;
|
||||
uniform float bendInfluence;
|
||||
|
||||
uniform bool parallax;
|
||||
uniform float parallaxStrength;
|
||||
uniform vec2 parallaxOffset;
|
||||
|
||||
uniform vec3 lineGradient[8];
|
||||
uniform int lineGradientCount;
|
||||
|
||||
const vec3 BLACK = vec3(0.0);
|
||||
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
|
||||
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
|
||||
|
||||
mat2 rotate(float r) {
|
||||
return mat2(cos(r), sin(r), -sin(r), cos(r));
|
||||
}
|
||||
|
||||
vec3 background_color(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
|
||||
float m = uv.y - y;
|
||||
|
||||
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
|
||||
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
|
||||
return col * 0.5;
|
||||
}
|
||||
|
||||
vec3 getLineColor(float t, vec3 baseColor) {
|
||||
if (lineGradientCount <= 0) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
vec3 gradientColor;
|
||||
|
||||
if (lineGradientCount == 1) {
|
||||
gradientColor = lineGradient[0];
|
||||
} else {
|
||||
float clampedT = clamp(t, 0.0, 0.9999);
|
||||
float scaled = clampedT * float(lineGradientCount - 1);
|
||||
int idx = int(floor(scaled));
|
||||
float f = fract(scaled);
|
||||
int idx2 = min(idx + 1, lineGradientCount - 1);
|
||||
|
||||
vec3 c1 = lineGradient[idx];
|
||||
vec3 c2 = lineGradient[idx2];
|
||||
|
||||
gradientColor = mix(c1, c2, f);
|
||||
}
|
||||
|
||||
return gradientColor * 0.5;
|
||||
}
|
||||
|
||||
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
|
||||
float time = iTime * animationSpeed;
|
||||
|
||||
float x_offset = offset;
|
||||
float x_movement = time * 0.1;
|
||||
float amp = sin(offset + time * 0.2) * 0.3;
|
||||
float y = sin(uv.x + x_offset + x_movement) * amp;
|
||||
|
||||
if (shouldBend) {
|
||||
vec2 d = screenUv - mouseUv;
|
||||
float influence = exp(-dot(d, d) * bendRadius);
|
||||
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
|
||||
y += bendOffset;
|
||||
}
|
||||
|
||||
float m = uv.y - y;
|
||||
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
|
||||
}
|
||||
|
||||
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
|
||||
baseUv.y *= -1.0;
|
||||
|
||||
if (parallax) {
|
||||
baseUv += parallaxOffset;
|
||||
}
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec3 b = lineGradientCount > 0 ? vec3(0.0) : background_color(baseUv);
|
||||
|
||||
vec2 mouseUv = vec2(0.0);
|
||||
if (interactive) {
|
||||
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
||||
mouseUv.y *= -1.0;
|
||||
}
|
||||
|
||||
if (enableBottom) {
|
||||
for (int i = 0; i < bottomLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(bottomLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
||||
1.5 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableMiddle) {
|
||||
for (int i = 0; i < middleLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(middleLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = middleWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(middleLineDistance * fi + middleWavePosition.x, middleWavePosition.y),
|
||||
2.0 + 0.15 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableTop) {
|
||||
for (int i = 0; i < topLineCount; ++i) {
|
||||
float fi = float(i);
|
||||
float t = fi / max(float(topLineCount - 1), 1.0);
|
||||
vec3 lineCol = getLineColor(t, b);
|
||||
|
||||
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
|
||||
vec2 ruv = baseUv * rotate(angle);
|
||||
ruv.x *= -1.0;
|
||||
col += lineCol * wave(
|
||||
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
|
||||
1.0 + 0.2 * fi,
|
||||
baseUv,
|
||||
mouseUv,
|
||||
interactive
|
||||
) * 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
fragColor = vec4(col, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = vec4(0.0);
|
||||
mainImage(color, gl_FragCoord.xy);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_GRADIENT_STOPS = 8;
|
||||
|
||||
type WavePosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
rotate: number;
|
||||
};
|
||||
|
||||
type FloatingLinesProps = {
|
||||
linesGradient?: string[];
|
||||
enabledWaves?: Array<'top' | 'middle' | 'bottom'>;
|
||||
lineCount?: number | number[];
|
||||
lineDistance?: number | number[];
|
||||
topWavePosition?: WavePosition;
|
||||
middleWavePosition?: WavePosition;
|
||||
bottomWavePosition?: WavePosition;
|
||||
animationSpeed?: number;
|
||||
interactive?: boolean;
|
||||
bendRadius?: number;
|
||||
bendStrength?: number;
|
||||
mouseDamping?: number;
|
||||
parallax?: boolean;
|
||||
parallaxStrength?: number;
|
||||
pointerTarget?: 'self' | 'window';
|
||||
mixBlendMode?: CSSProperties['mixBlendMode'];
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<FloatingLinesProps>(), {
|
||||
enabledWaves: () => ['top', 'middle', 'bottom'],
|
||||
lineCount: () => [6],
|
||||
lineDistance: () => [5],
|
||||
bottomWavePosition: () => ({ x: 2.0, y: -0.7, rotate: -1 }),
|
||||
animationSpeed: 1,
|
||||
interactive: true,
|
||||
bendRadius: 5.0,
|
||||
bendStrength: -0.5,
|
||||
mouseDamping: 0.05,
|
||||
parallax: true,
|
||||
parallaxStrength: 0.2,
|
||||
pointerTarget: 'self',
|
||||
mixBlendMode: 'screen'
|
||||
});
|
||||
|
||||
function hexToVec3(hex: string): Vector3 {
|
||||
let value = hex.trim();
|
||||
|
||||
if (value.startsWith('#')) {
|
||||
value = value.slice(1);
|
||||
}
|
||||
|
||||
let r = 255;
|
||||
let g = 255;
|
||||
let b = 255;
|
||||
|
||||
if (value.length === 3) {
|
||||
r = parseInt(value[0] + value[0], 16);
|
||||
g = parseInt(value[1] + value[1], 16);
|
||||
b = parseInt(value[2] + value[2], 16);
|
||||
} else if (value.length === 6) {
|
||||
r = parseInt(value.slice(0, 2), 16);
|
||||
g = parseInt(value.slice(2, 4), 16);
|
||||
b = parseInt(value.slice(4, 6), 16);
|
||||
}
|
||||
|
||||
return new Vector3(r / 255, g / 255, b / 255);
|
||||
}
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const targetMouseRef = ref<Vector2>(new Vector2(-1000, -1000));
|
||||
const currentMouseRef = ref<Vector2>(new Vector2(-1000, -1000));
|
||||
const targetInfluenceRef = ref<number>(0);
|
||||
const currentInfluenceRef = ref<number>(0);
|
||||
const targetParallaxRef = ref<Vector2>(new Vector2(0, 0));
|
||||
const currentParallaxRef = ref<Vector2>(new Vector2(0, 0));
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
const setup = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const getLineCount = (waveType: 'top' | 'middle' | 'bottom'): number => {
|
||||
if (typeof props.lineCount === 'number') return props.lineCount;
|
||||
if (!props.enabledWaves.includes(waveType)) return 0;
|
||||
const index = props.enabledWaves.indexOf(waveType);
|
||||
return props.lineCount[index] ?? 6;
|
||||
};
|
||||
|
||||
const getLineDistance = (waveType: 'top' | 'middle' | 'bottom'): number => {
|
||||
if (typeof props.lineDistance === 'number') return props.lineDistance;
|
||||
if (!props.enabledWaves.includes(waveType)) return 0.1;
|
||||
const index = props.enabledWaves.indexOf(waveType);
|
||||
return props.lineDistance[index] ?? 0.1;
|
||||
};
|
||||
|
||||
const topLineCount = props.enabledWaves.includes('top') ? getLineCount('top') : 0;
|
||||
const middleLineCount = props.enabledWaves.includes('middle') ? getLineCount('middle') : 0;
|
||||
const bottomLineCount = props.enabledWaves.includes('bottom') ? getLineCount('bottom') : 0;
|
||||
|
||||
const topLineDistance = props.enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01;
|
||||
const middleLineDistance = props.enabledWaves.includes('middle') ? getLineDistance('middle') * 0.01 : 0.01;
|
||||
const bottomLineDistance = props.enabledWaves.includes('bottom') ? getLineDistance('bottom') * 0.01 : 0.01;
|
||||
|
||||
const scene = new Scene();
|
||||
|
||||
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
camera.position.z = 1;
|
||||
|
||||
const renderer = new WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
containerRef.value.appendChild(renderer.domElement);
|
||||
|
||||
const uniforms = {
|
||||
iTime: { value: 0 },
|
||||
iResolution: { value: new Vector3(1, 1, 1) },
|
||||
animationSpeed: { value: props.animationSpeed },
|
||||
|
||||
enableTop: { value: props.enabledWaves.includes('top') },
|
||||
enableMiddle: { value: props.enabledWaves.includes('middle') },
|
||||
enableBottom: { value: props.enabledWaves.includes('bottom') },
|
||||
|
||||
topLineCount: { value: topLineCount },
|
||||
middleLineCount: { value: middleLineCount },
|
||||
bottomLineCount: { value: bottomLineCount },
|
||||
|
||||
topLineDistance: { value: topLineDistance },
|
||||
middleLineDistance: { value: middleLineDistance },
|
||||
bottomLineDistance: { value: bottomLineDistance },
|
||||
|
||||
topWavePosition: {
|
||||
value: new Vector3(
|
||||
props.topWavePosition?.x ?? 10.0,
|
||||
props.topWavePosition?.y ?? 0.5,
|
||||
props.topWavePosition?.rotate ?? -0.4
|
||||
)
|
||||
},
|
||||
middleWavePosition: {
|
||||
value: new Vector3(
|
||||
props.middleWavePosition?.x ?? 5.0,
|
||||
props.middleWavePosition?.y ?? 0.0,
|
||||
props.middleWavePosition?.rotate ?? 0.2
|
||||
)
|
||||
},
|
||||
bottomWavePosition: {
|
||||
value: new Vector3(
|
||||
props.bottomWavePosition?.x ?? 2.0,
|
||||
props.bottomWavePosition?.y ?? -0.7,
|
||||
props.bottomWavePosition?.rotate ?? 0.4
|
||||
)
|
||||
},
|
||||
|
||||
iMouse: { value: new Vector2(-1000, -1000) },
|
||||
interactive: { value: props.interactive },
|
||||
bendRadius: { value: props.bendRadius },
|
||||
bendStrength: { value: props.bendStrength },
|
||||
bendInfluence: { value: 0 },
|
||||
|
||||
parallax: { value: props.parallax },
|
||||
parallaxStrength: { value: props.parallaxStrength },
|
||||
parallaxOffset: { value: new Vector2(0, 0) },
|
||||
|
||||
lineGradient: {
|
||||
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1))
|
||||
},
|
||||
lineGradientCount: { value: 0 }
|
||||
};
|
||||
|
||||
if (props.linesGradient && props.linesGradient.length > 0) {
|
||||
const stops = props.linesGradient.slice(0, MAX_GRADIENT_STOPS);
|
||||
uniforms.lineGradientCount.value = stops.length;
|
||||
|
||||
stops.forEach((hex, i) => {
|
||||
const color = hexToVec3(hex);
|
||||
uniforms.lineGradient.value[i].set(color.x, color.y, color.z);
|
||||
});
|
||||
}
|
||||
|
||||
const material = new ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader
|
||||
});
|
||||
|
||||
const geometry = new PlaneGeometry(2, 2);
|
||||
const mesh = new Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
const clock = new Clock();
|
||||
|
||||
const setSize = () => {
|
||||
const el = containerRef.value!;
|
||||
const width = el.clientWidth || 1;
|
||||
const height = el.clientHeight || 1;
|
||||
|
||||
renderer.setSize(width, height, false);
|
||||
|
||||
const canvasWidth = renderer.domElement.width;
|
||||
const canvasHeight = renderer.domElement.height;
|
||||
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1);
|
||||
};
|
||||
|
||||
setSize();
|
||||
|
||||
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(setSize) : null;
|
||||
|
||||
if (ro && containerRef.value) {
|
||||
ro.observe(containerRef.value);
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const dpr = renderer.getPixelRatio();
|
||||
|
||||
targetMouseRef.value.set(x * dpr, (rect.height - y) * dpr);
|
||||
targetInfluenceRef.value = 1.0;
|
||||
|
||||
if (props.parallax) {
|
||||
const centerX = rect.width / 2;
|
||||
const centerY = rect.height / 2;
|
||||
const offsetX = (x - centerX) / rect.width;
|
||||
const offsetY = -(y - centerY) / rect.height;
|
||||
targetParallaxRef.value.set(offsetX * props.parallaxStrength, offsetY * props.parallaxStrength);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
targetInfluenceRef.value = 0.0;
|
||||
};
|
||||
|
||||
const pointerEventTarget: Window | HTMLElement = props.pointerTarget === 'window'
|
||||
? window
|
||||
: renderer.domElement;
|
||||
|
||||
if (props.interactive) {
|
||||
pointerEventTarget.addEventListener('pointermove', handlePointerMove);
|
||||
pointerEventTarget.addEventListener('pointerleave', handlePointerLeave);
|
||||
if (props.pointerTarget === 'window') {
|
||||
window.addEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
}
|
||||
|
||||
let raf = 0;
|
||||
const renderLoop = () => {
|
||||
uniforms.iTime.value = clock.getElapsedTime();
|
||||
|
||||
if (props.interactive) {
|
||||
currentMouseRef.value.lerp(targetMouseRef.value, props.mouseDamping);
|
||||
uniforms.iMouse.value.copy(currentMouseRef.value);
|
||||
|
||||
currentInfluenceRef.value += (targetInfluenceRef.value - currentInfluenceRef.value) * props.mouseDamping;
|
||||
uniforms.bendInfluence.value = currentInfluenceRef.value;
|
||||
}
|
||||
|
||||
if (props.parallax) {
|
||||
currentParallaxRef.value.lerp(targetParallaxRef.value, props.mouseDamping);
|
||||
uniforms.parallaxOffset.value.copy(currentParallaxRef.value);
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(renderLoop);
|
||||
};
|
||||
renderLoop();
|
||||
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
if (ro && containerRef.value) {
|
||||
ro.disconnect();
|
||||
}
|
||||
|
||||
if (props.interactive) {
|
||||
pointerEventTarget.removeEventListener('pointermove', handlePointerMove);
|
||||
pointerEventTarget.removeEventListener('pointerleave', handlePointerLeave);
|
||||
if (props.pointerTarget === 'window') {
|
||||
window.removeEventListener('blur', handlePointerLeave);
|
||||
}
|
||||
}
|
||||
|
||||
geometry.dispose();
|
||||
material.dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentElement) {
|
||||
renderer.domElement.parentElement.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setup();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.linesGradient,
|
||||
props.enabledWaves,
|
||||
props.lineCount,
|
||||
props.lineDistance,
|
||||
props.topWavePosition,
|
||||
props.middleWavePosition,
|
||||
props.bottomWavePosition,
|
||||
props.animationSpeed,
|
||||
props.interactive,
|
||||
props.bendRadius,
|
||||
props.bendStrength,
|
||||
props.mouseDamping,
|
||||
props.parallax,
|
||||
props.parallaxStrength,
|
||||
props.pointerTarget
|
||||
],
|
||||
() => {
|
||||
cleanup?.();
|
||||
setup();
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative w-full h-full overflow-hidden floating-lines-container"
|
||||
:style="{
|
||||
mixBlendMode: mixBlendMode
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
369
src/components/vue-bits/Galaxy.vue
Normal file
369
src/components/vue-bits/Galaxy.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 uv;
|
||||
attribute vec2 position;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0, 1);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec3 uResolution;
|
||||
uniform vec2 uFocal;
|
||||
uniform vec2 uRotation;
|
||||
uniform float uStarSpeed;
|
||||
uniform float uDensity;
|
||||
uniform float uHueShift;
|
||||
uniform float uSpeed;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uGlowIntensity;
|
||||
uniform float uSaturation;
|
||||
uniform bool uMouseRepulsion;
|
||||
uniform float uTwinkleIntensity;
|
||||
uniform float uRotationSpeed;
|
||||
uniform float uRepulsionStrength;
|
||||
uniform float uMouseActiveFactor;
|
||||
uniform float uAutoCenterRepulsion;
|
||||
uniform bool uTransparent;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#define NUM_LAYER 4.0
|
||||
#define STAR_COLOR_CUTOFF 0.2
|
||||
#define MAT45 mat2(0.7071, -0.7071, 0.7071, 0.7071)
|
||||
#define PERIOD 3.0
|
||||
|
||||
float Hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float tri(float x) {
|
||||
return abs(fract(x) * 2.0 - 1.0);
|
||||
}
|
||||
|
||||
float tris(float x) {
|
||||
float t = fract(x);
|
||||
return 1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0));
|
||||
}
|
||||
|
||||
float trisn(float x) {
|
||||
float t = fract(x);
|
||||
return 2.0 * (1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0))) - 1.0;
|
||||
}
|
||||
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
|
||||
float Star(vec2 uv, float flare) {
|
||||
float d = length(uv);
|
||||
float m = (0.05 * uGlowIntensity) / d;
|
||||
float rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
||||
m += rays * flare * uGlowIntensity;
|
||||
uv *= MAT45;
|
||||
rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
|
||||
m += rays * 0.3 * flare * uGlowIntensity;
|
||||
m *= smoothstep(1.0, 0.2, d);
|
||||
return m;
|
||||
}
|
||||
|
||||
vec3 StarLayer(vec2 uv) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec2 gv = fract(uv) - 0.5;
|
||||
vec2 id = floor(uv);
|
||||
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 offset = vec2(float(x), float(y));
|
||||
vec2 si = id + vec2(float(x), float(y));
|
||||
float seed = Hash21(si);
|
||||
float size = fract(seed * 345.32);
|
||||
float glossLocal = tri(uStarSpeed / (PERIOD * seed + 1.0));
|
||||
float flareSize = smoothstep(0.9, 1.0, size) * glossLocal;
|
||||
|
||||
float red = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 1.0)) + STAR_COLOR_CUTOFF;
|
||||
float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF;
|
||||
float grn = min(red, blu) * seed;
|
||||
vec3 base = vec3(red, grn, blu);
|
||||
|
||||
float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5;
|
||||
hue = fract(hue + uHueShift / 360.0);
|
||||
float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * uSaturation;
|
||||
float val = max(max(base.r, base.g), base.b);
|
||||
base = hsv2rgb(vec3(hue, sat, val));
|
||||
|
||||
vec2 pad = vec2(tris(seed * 34.0 + uTime * uSpeed / 10.0), tris(seed * 38.0 + uTime * uSpeed / 30.0)) - 0.5;
|
||||
|
||||
float star = Star(gv - offset - pad, flareSize);
|
||||
vec3 color = base;
|
||||
|
||||
float twinkle = trisn(uTime * uSpeed + seed * 6.2831) * 0.5 + 1.0;
|
||||
twinkle = mix(1.0, twinkle, uTwinkleIntensity);
|
||||
star *= twinkle;
|
||||
|
||||
col += star * size * color;
|
||||
}
|
||||
}
|
||||
|
||||
return col;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 focalPx = uFocal * uResolution.xy;
|
||||
vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y;
|
||||
|
||||
vec2 mouseNorm = uMouse - vec2(0.5);
|
||||
|
||||
if (uAutoCenterRepulsion > 0.0) {
|
||||
vec2 centerUV = vec2(0.0, 0.0); // Center in UV space
|
||||
float centerDist = length(uv - centerUV);
|
||||
vec2 repulsion = normalize(uv - centerUV) * (uAutoCenterRepulsion / (centerDist + 0.1));
|
||||
uv += repulsion * 0.05;
|
||||
} else if (uMouseRepulsion) {
|
||||
vec2 mousePosUV = (uMouse * uResolution.xy - focalPx) / uResolution.y;
|
||||
float mouseDist = length(uv - mousePosUV);
|
||||
vec2 repulsion = normalize(uv - mousePosUV) * (uRepulsionStrength / (mouseDist + 0.1));
|
||||
uv += repulsion * 0.05 * uMouseActiveFactor;
|
||||
} else {
|
||||
vec2 mouseOffset = mouseNorm * 0.1 * uMouseActiveFactor;
|
||||
uv += mouseOffset;
|
||||
}
|
||||
|
||||
float autoRotAngle = uTime * uRotationSpeed;
|
||||
mat2 autoRot = mat2(cos(autoRotAngle), -sin(autoRotAngle), sin(autoRotAngle), cos(autoRotAngle));
|
||||
uv = autoRot * uv;
|
||||
|
||||
uv = mat2(uRotation.x, -uRotation.y, uRotation.y, uRotation.x) * uv;
|
||||
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
for (float i = 0.0; i < 1.0; i += 1.0 / NUM_LAYER) {
|
||||
float depth = fract(i + uStarSpeed * uSpeed);
|
||||
float scale = mix(20.0 * uDensity, 0.5 * uDensity, depth);
|
||||
float fade = depth * smoothstep(1.0, 0.9, depth);
|
||||
col += StarLayer(uv * scale + i * 453.32) * fade;
|
||||
}
|
||||
|
||||
if (uTransparent) {
|
||||
float alpha = length(col);
|
||||
alpha = smoothstep(0.0, 0.3, alpha); // Enhance contrast
|
||||
alpha = min(alpha, 1.0); // Clamp to maximum 1.0
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
} else {
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface GalaxyProps {
|
||||
focal?: [number, number];
|
||||
rotation?: [number, number];
|
||||
starSpeed?: number;
|
||||
density?: number;
|
||||
hueShift?: number;
|
||||
disableAnimation?: boolean;
|
||||
speed?: number;
|
||||
mouseInteraction?: boolean;
|
||||
glowIntensity?: number;
|
||||
saturation?: number;
|
||||
mouseRepulsion?: boolean;
|
||||
twinkleIntensity?: number;
|
||||
rotationSpeed?: number;
|
||||
repulsionStrength?: number;
|
||||
autoCenterRepulsion?: number;
|
||||
transparent?: boolean;
|
||||
pointerTarget?: 'self' | 'window';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GalaxyProps>(), {
|
||||
focal: () => [0.5, 0.5],
|
||||
rotation: () => [1.0, 0.0],
|
||||
starSpeed: 0.5,
|
||||
density: 1,
|
||||
hueShift: 140,
|
||||
disableAnimation: false,
|
||||
speed: 1.0,
|
||||
mouseInteraction: true,
|
||||
glowIntensity: 0.3,
|
||||
saturation: 0.0,
|
||||
mouseRepulsion: true,
|
||||
twinkleIntensity: 0.3,
|
||||
rotationSpeed: 0.1,
|
||||
repulsionStrength: 2,
|
||||
autoCenterRepulsion: 0,
|
||||
transparent: true,
|
||||
pointerTarget: 'self'
|
||||
});
|
||||
|
||||
const ctnDom = useTemplateRef('ctnDom');
|
||||
const targetMousePos = ref({ x: 0.5, y: 0.5 });
|
||||
const smoothMousePos = ref({ x: 0.5, y: 0.5 });
|
||||
const targetMouseActive = ref(0.0);
|
||||
const smoothMouseActive = ref(0.0);
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const setup = () => {
|
||||
if (!ctnDom.value) return;
|
||||
const ctn = ctnDom.value;
|
||||
const renderer = new Renderer({
|
||||
alpha: props.transparent,
|
||||
premultipliedAlpha: false
|
||||
});
|
||||
const gl = renderer.gl;
|
||||
|
||||
if (props.transparent) {
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
} else {
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
let program: Program;
|
||||
|
||||
function resize() {
|
||||
const scale = 1;
|
||||
renderer.setSize(ctn.offsetWidth * scale, ctn.offsetHeight * scale);
|
||||
if (program) {
|
||||
program.uniforms.uResolution.value = new Color(
|
||||
gl.canvas.width,
|
||||
gl.canvas.height,
|
||||
gl.canvas.width / gl.canvas.height
|
||||
);
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', resize, false);
|
||||
resize();
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
uResolution: {
|
||||
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
uFocal: { value: new Float32Array(props.focal) },
|
||||
uRotation: { value: new Float32Array(props.rotation) },
|
||||
uStarSpeed: { value: props.starSpeed },
|
||||
uDensity: { value: props.density },
|
||||
uHueShift: { value: props.hueShift },
|
||||
uSpeed: { value: props.speed },
|
||||
uMouse: {
|
||||
value: new Float32Array([smoothMousePos.value.x, smoothMousePos.value.y])
|
||||
},
|
||||
uGlowIntensity: { value: props.glowIntensity },
|
||||
uSaturation: { value: props.saturation },
|
||||
uMouseRepulsion: { value: props.mouseRepulsion },
|
||||
uTwinkleIntensity: { value: props.twinkleIntensity },
|
||||
uRotationSpeed: { value: props.rotationSpeed },
|
||||
uRepulsionStrength: { value: props.repulsionStrength },
|
||||
uMouseActiveFactor: { value: 0.0 },
|
||||
uAutoCenterRepulsion: { value: props.autoCenterRepulsion },
|
||||
uTransparent: { value: props.transparent }
|
||||
}
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
let animateId: number;
|
||||
|
||||
function update(t: number) {
|
||||
animateId = requestAnimationFrame(update);
|
||||
if (!props.disableAnimation) {
|
||||
program.uniforms.uTime.value = t * 0.001;
|
||||
program.uniforms.uStarSpeed.value = (t * 0.001 * props.starSpeed) / 10.0;
|
||||
}
|
||||
|
||||
const lerpFactor = 0.05;
|
||||
smoothMousePos.value.x += (targetMousePos.value.x - smoothMousePos.value.x) * lerpFactor;
|
||||
smoothMousePos.value.y += (targetMousePos.value.y - smoothMousePos.value.y) * lerpFactor;
|
||||
|
||||
smoothMouseActive.value += (targetMouseActive.value - smoothMouseActive.value) * lerpFactor;
|
||||
|
||||
program.uniforms.uMouse.value[0] = smoothMousePos.value.x;
|
||||
program.uniforms.uMouse.value[1] = smoothMousePos.value.y;
|
||||
program.uniforms.uMouseActiveFactor.value = smoothMouseActive.value;
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
}
|
||||
animateId = requestAnimationFrame(update);
|
||||
ctn.appendChild(gl.canvas);
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
const rect = ctn.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = 1.0 - (e.clientY - rect.top) / rect.height;
|
||||
targetMousePos.value = { x, y };
|
||||
targetMouseActive.value = 1.0;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
targetMouseActive.value = 0.0;
|
||||
}
|
||||
|
||||
const pointerEventTarget: Window | HTMLElement = props.pointerTarget === 'window'
|
||||
? window
|
||||
: ctn;
|
||||
|
||||
if (props.mouseInteraction) {
|
||||
pointerEventTarget.addEventListener('mousemove', handleMouseMove);
|
||||
pointerEventTarget.addEventListener('mouseleave', handleMouseLeave);
|
||||
if (props.pointerTarget === 'window') {
|
||||
window.addEventListener('blur', handleMouseLeave);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (props.mouseInteraction) {
|
||||
pointerEventTarget.removeEventListener('mousemove', handleMouseMove);
|
||||
pointerEventTarget.removeEventListener('mouseleave', handleMouseLeave);
|
||||
if (props.pointerTarget === 'window') {
|
||||
window.removeEventListener('blur', handleMouseLeave);
|
||||
}
|
||||
}
|
||||
ctn.removeChild(gl.canvas);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
cleanup?.();
|
||||
setup();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props,
|
||||
() => {
|
||||
cleanup?.();
|
||||
setup();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="ctnDom" class="relative w-full h-full" v-bind="$attrs" />
|
||||
</template>
|
||||
92
src/components/vue-bits/Magnet.vue
Normal file
92
src/components/vue-bits/Magnet.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div
|
||||
ref="magnetRef"
|
||||
:class="wrapperClassName"
|
||||
:style="{ position: 'relative', display: 'inline-block' }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
:class="innerClassName"
|
||||
:style="{
|
||||
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
|
||||
transition: transitionStyle,
|
||||
willChange: 'transform'
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
|
||||
|
||||
interface Props {
|
||||
padding?: number;
|
||||
disabled?: boolean;
|
||||
magnetStrength?: number;
|
||||
activeTransition?: string;
|
||||
inactiveTransition?: string;
|
||||
wrapperClassName?: string;
|
||||
innerClassName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
padding: 100,
|
||||
disabled: false,
|
||||
magnetStrength: 2,
|
||||
activeTransition: 'transform 0.3s ease-out',
|
||||
inactiveTransition: 'transform 0.5s ease-in-out',
|
||||
wrapperClassName: '',
|
||||
innerClassName: ''
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
const magnetRef = useTemplateRef<HTMLDivElement>('magnetRef');
|
||||
const isActive = ref(false);
|
||||
const position = ref({ x: 0, y: 0 });
|
||||
|
||||
const transitionStyle = computed(() => (isActive.value ? props.activeTransition : props.inactiveTransition));
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!magnetRef.value || props.disabled) return;
|
||||
|
||||
const { left, top, width, height } = magnetRef.value.getBoundingClientRect();
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
|
||||
const distX = Math.abs(centerX - e.clientX);
|
||||
const distY = Math.abs(centerY - e.clientY);
|
||||
|
||||
if (distX < width / 2 + props.padding && distY < height / 2 + props.padding) {
|
||||
isActive.value = true;
|
||||
const offsetX = (e.clientX - centerX) / props.magnetStrength;
|
||||
const offsetY = (e.clientY - centerY) / props.magnetStrength;
|
||||
position.value = { x: offsetX, y: offsetY };
|
||||
} else {
|
||||
isActive.value = false;
|
||||
position.value = { x: 0, y: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
newDisabled => {
|
||||
if (newDisabled) {
|
||||
position.value = { x: 0, y: 0 };
|
||||
isActive.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
357
src/components/vue-bits/ScrollStack.vue
Normal file
357
src/components/vue-bits/ScrollStack.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import Lenis from 'lenis';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
interface CardTransform {
|
||||
translateY: number;
|
||||
scale: number;
|
||||
rotation: number;
|
||||
blur: number;
|
||||
}
|
||||
|
||||
interface ScrollStackProps {
|
||||
className?: string;
|
||||
itemDistance?: number;
|
||||
itemScale?: number;
|
||||
itemStackDistance?: number;
|
||||
stackPosition?: string;
|
||||
scaleEndPosition?: string;
|
||||
baseScale?: number;
|
||||
scaleDuration?: number;
|
||||
rotationAmount?: number;
|
||||
blurAmount?: number;
|
||||
useWindowScroll?: boolean;
|
||||
onStackComplete?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ScrollStackProps>(), {
|
||||
className: '',
|
||||
itemDistance: 100,
|
||||
itemScale: 0.03,
|
||||
itemStackDistance: 30,
|
||||
stackPosition: '20%',
|
||||
scaleEndPosition: '10%',
|
||||
baseScale: 0.85,
|
||||
scaleDuration: 0.5,
|
||||
rotationAmount: 0,
|
||||
blurAmount: 0,
|
||||
useWindowScroll: false
|
||||
});
|
||||
|
||||
const scrollerRef = useTemplateRef('scrollerRef');
|
||||
const stackCompletedRef = ref(false);
|
||||
const animationFrameRef = ref<number | null>(null);
|
||||
const lenisRef = ref<Lenis | null>(null);
|
||||
const cardsRef = ref<HTMLElement[]>([]);
|
||||
const lastTransformsRef = ref(new Map<number, CardTransform>());
|
||||
const isUpdatingRef = ref(false);
|
||||
|
||||
const calculateProgress = (scrollTop: number, start: number, end: number) => {
|
||||
if (scrollTop < start) return 0;
|
||||
if (scrollTop > end) return 1;
|
||||
return (scrollTop - start) / (end - start);
|
||||
};
|
||||
|
||||
const parsePercentage = (value: string | number, containerHeight: number) => {
|
||||
if (typeof value === 'string' && value.includes('%')) {
|
||||
return (parseFloat(value) / 100) * containerHeight;
|
||||
}
|
||||
return parseFloat(value as string);
|
||||
};
|
||||
|
||||
const getScrollData = () => {
|
||||
if (props.useWindowScroll) {
|
||||
return {
|
||||
scrollTop: window.scrollY,
|
||||
containerHeight: window.innerHeight,
|
||||
scrollContainer: document.documentElement
|
||||
};
|
||||
} else {
|
||||
const scroller = scrollerRef.value!;
|
||||
return {
|
||||
scrollTop: scroller.scrollTop,
|
||||
containerHeight: scroller.clientHeight,
|
||||
scrollContainer: scroller
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getElementOffset = (element: HTMLElement) => {
|
||||
if (props.useWindowScroll) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
} else {
|
||||
return element.offsetTop;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCardTransforms = () => {
|
||||
if (!cardsRef.value.length || isUpdatingRef.value) return;
|
||||
|
||||
if (!props.useWindowScroll && !scrollerRef.value) return;
|
||||
|
||||
isUpdatingRef.value = true;
|
||||
|
||||
const { scrollTop, containerHeight } = getScrollData();
|
||||
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
|
||||
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
|
||||
|
||||
const endElement = props.useWindowScroll
|
||||
? (document.querySelector('.scroll-stack-end') as HTMLElement | null)
|
||||
: (scrollerRef.value?.querySelector('.scroll-stack-end') as HTMLElement | null);
|
||||
|
||||
const endElementTop = endElement ? getElementOffset(endElement) : 0;
|
||||
|
||||
cardsRef.value.forEach((card, i) => {
|
||||
if (!card) return;
|
||||
|
||||
const cardTop = getElementOffset(card);
|
||||
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
|
||||
const triggerEnd = cardTop - scaleEndPositionPx;
|
||||
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
|
||||
const pinEnd = endElementTop - containerHeight / 2;
|
||||
|
||||
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
|
||||
const targetScale = props.baseScale + i * props.itemScale;
|
||||
const scale = 1 - scaleProgress * (1 - targetScale);
|
||||
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
|
||||
|
||||
let blur = 0;
|
||||
if (props.blurAmount) {
|
||||
let topCardIndex = 0;
|
||||
for (let j = 0; j < cardsRef.value.length; j++) {
|
||||
const jCardTop = getElementOffset(cardsRef.value[j]);
|
||||
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
|
||||
if (scrollTop >= jTriggerStart) {
|
||||
topCardIndex = j;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < topCardIndex) {
|
||||
const depthInStack = topCardIndex - i;
|
||||
blur = Math.max(0, depthInStack * props.blurAmount);
|
||||
}
|
||||
}
|
||||
|
||||
let translateY = 0;
|
||||
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
|
||||
if (isPinned) {
|
||||
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
|
||||
} else if (scrollTop > pinEnd) {
|
||||
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
|
||||
}
|
||||
|
||||
const newTransform = {
|
||||
translateY: Math.round(translateY * 100) / 100,
|
||||
scale: Math.round(scale * 1000) / 1000,
|
||||
rotation: Math.round(rotation * 100) / 100,
|
||||
blur: Math.round(blur * 100) / 100
|
||||
};
|
||||
|
||||
const lastTransform = lastTransformsRef.value.get(i);
|
||||
const hasChanged =
|
||||
!lastTransform ||
|
||||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
|
||||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
|
||||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
|
||||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
|
||||
|
||||
if (hasChanged) {
|
||||
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
|
||||
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
|
||||
|
||||
card.style.transform = transform;
|
||||
card.style.filter = filter;
|
||||
|
||||
lastTransformsRef.value.set(i, newTransform);
|
||||
}
|
||||
|
||||
if (i === cardsRef.value.length - 1) {
|
||||
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
|
||||
if (isInView && !stackCompletedRef.value) {
|
||||
stackCompletedRef.value = true;
|
||||
props.onStackComplete?.();
|
||||
} else if (!isInView && stackCompletedRef.value) {
|
||||
stackCompletedRef.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isUpdatingRef.value = false;
|
||||
};
|
||||
|
||||
const handleScroll = () => updateCardTransforms();
|
||||
|
||||
const setupLenis = () => {
|
||||
if (props.useWindowScroll) {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 2,
|
||||
infinite: false,
|
||||
wheelMultiplier: 1,
|
||||
lerp: 0.1,
|
||||
syncTouch: true,
|
||||
syncTouchLerp: 0.075
|
||||
});
|
||||
|
||||
lenis.on('scroll', handleScroll);
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
};
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
|
||||
lenisRef.value = lenis;
|
||||
return lenis;
|
||||
} else {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!scroller) return;
|
||||
|
||||
const lenis = new Lenis({
|
||||
wrapper: scroller,
|
||||
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
|
||||
duration: 1.2,
|
||||
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
touchMultiplier: 2,
|
||||
infinite: false,
|
||||
gestureOrientation: 'vertical',
|
||||
wheelMultiplier: 1,
|
||||
lerp: 0.1,
|
||||
syncTouch: true,
|
||||
syncTouchLerp: 0.075
|
||||
});
|
||||
|
||||
lenis.on('scroll', handleScroll);
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis.raf(time);
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
};
|
||||
animationFrameRef.value = requestAnimationFrame(raf);
|
||||
|
||||
lenisRef.value = lenis;
|
||||
return lenis;
|
||||
}
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
|
||||
const setup = () => {
|
||||
const scroller = scrollerRef.value;
|
||||
if (!props.useWindowScroll && !scroller) return;
|
||||
|
||||
const cards = Array.from(
|
||||
props.useWindowScroll
|
||||
? document.querySelectorAll('.scroll-stack-card')
|
||||
: scroller!.querySelectorAll('.scroll-stack-card')
|
||||
) as HTMLElement[];
|
||||
|
||||
cardsRef.value = cards;
|
||||
const transformsCache = lastTransformsRef.value;
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
if (i < cards.length - 1) {
|
||||
card.style.marginBottom = `${props.itemDistance}px`;
|
||||
}
|
||||
card.style.willChange = 'transform, filter';
|
||||
card.style.transformOrigin = 'top center';
|
||||
card.style.backfaceVisibility = 'hidden';
|
||||
card.style.transform = 'translateZ(0)';
|
||||
(card.style as CSSStyleDeclaration & { webkitTransform: string }).webkitTransform = 'translateZ(0)';
|
||||
card.style.perspective = '1000px';
|
||||
(card.style as CSSStyleDeclaration & { webkitPerspective: string }).webkitPerspective = '1000px';
|
||||
});
|
||||
|
||||
setupLenis();
|
||||
updateCardTransforms();
|
||||
|
||||
cleanup = () => {
|
||||
if (animationFrameRef.value) cancelAnimationFrame(animationFrameRef.value);
|
||||
if (lenisRef.value) lenisRef.value.destroy();
|
||||
stackCompletedRef.value = false;
|
||||
cardsRef.value = [];
|
||||
transformsCache.clear();
|
||||
isUpdatingRef.value = false;
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
setup();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => ({ ...props }),
|
||||
() => {
|
||||
cleanup?.();
|
||||
setup();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
export const ScrollStackItem = defineComponent({
|
||||
name: 'ScrollStackItem',
|
||||
props: {
|
||||
itemClassName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${props.itemClassName}`.trim(),
|
||||
style: {
|
||||
backfaceVisibility: 'hidden',
|
||||
transformStyle: 'preserve-3d'
|
||||
}
|
||||
},
|
||||
slots.default?.()
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!useWindowScroll"
|
||||
ref="scrollerRef"
|
||||
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
|
||||
:style="{
|
||||
overscrollBehavior: 'contain',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitTransform: 'translateZ(0)',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'scroll-position'
|
||||
}"
|
||||
>
|
||||
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
|
||||
<slot />
|
||||
<div class="w-full h-px scroll-stack-end" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else ref="scrollerRef" :class="['scroll-stack-window-wrapper', className]">
|
||||
<div class="scroll-stack-inner">
|
||||
<slot />
|
||||
<div class="w-full h-px scroll-stack-end" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
273
src/components/vue-bits/Silk.vue
Normal file
273
src/components/vue-bits/Silk.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div ref="containerRef" :class="className" :style="style" class="w-full h-full"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch, type CSSProperties, useTemplateRef } from 'vue';
|
||||
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl';
|
||||
|
||||
interface SilkProps {
|
||||
speed?: number;
|
||||
scale?: number;
|
||||
color?: string;
|
||||
noiseIntensity?: number;
|
||||
rotation?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SilkProps>(), {
|
||||
speed: 5,
|
||||
scale: 1,
|
||||
color: '#7B7481',
|
||||
noiseIntensity: 1.5,
|
||||
rotation: 0,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
});
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||
|
||||
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
|
||||
const clean = hex.replace('#', '');
|
||||
const r = parseInt(clean.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(clean.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(clean.slice(4, 6), 16) / 255;
|
||||
return [r, g, b];
|
||||
};
|
||||
|
||||
const vertexShader = `
|
||||
attribute vec2 uv;
|
||||
attribute vec3 position;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main() {
|
||||
vPosition = position;
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec3 vPosition;
|
||||
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uSpeed;
|
||||
uniform float uScale;
|
||||
uniform float uRotation;
|
||||
uniform float uNoiseIntensity;
|
||||
|
||||
const float e = 2.71828182845904523536;
|
||||
|
||||
float noise(vec2 texCoord) {
|
||||
float G = e;
|
||||
vec2 r = (G * sin(G * texCoord));
|
||||
return fract(r.x * r.y * (1.0 + texCoord.x));
|
||||
}
|
||||
|
||||
vec2 rotateUvs(vec2 uv, float angle) {
|
||||
float c = cos(angle);
|
||||
float s = sin(angle);
|
||||
mat2 rot = mat2(c, -s, s, c);
|
||||
return rot * uv;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float rnd = noise(gl_FragCoord.xy);
|
||||
vec2 uv = rotateUvs(vUv * uScale, uRotation);
|
||||
vec2 tex = uv * uScale;
|
||||
float tOffset = uSpeed * uTime;
|
||||
|
||||
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
|
||||
|
||||
float pattern = 0.6 +
|
||||
0.4 * sin(5.0 * (tex.x + tex.y +
|
||||
cos(3.0 * tex.x + 5.0 * tex.y) +
|
||||
0.02 * tOffset) +
|
||||
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
|
||||
|
||||
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity;
|
||||
col.a = 1.0;
|
||||
gl_FragColor = col;
|
||||
}
|
||||
`;
|
||||
|
||||
let renderer: Renderer | null = null;
|
||||
let mesh: Mesh | null = null;
|
||||
let program: Program | null = null;
|
||||
let camera: Camera | null = null;
|
||||
let animateId = 0;
|
||||
|
||||
const initSilk = () => {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
renderer = new Renderer({
|
||||
alpha: true,
|
||||
antialias: true
|
||||
});
|
||||
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.canvas.style.backgroundColor = 'transparent';
|
||||
|
||||
camera = new Camera(gl, { fov: 75 });
|
||||
camera.position.z = 1;
|
||||
|
||||
const resize = () => {
|
||||
if (!container || !camera) return;
|
||||
|
||||
let width = container.offsetWidth;
|
||||
let height = container.offsetHeight;
|
||||
|
||||
let parent = container.parentElement;
|
||||
while (parent && (!width || !height)) {
|
||||
if (parent.offsetWidth && parent.offsetHeight) {
|
||||
width = parent.offsetWidth;
|
||||
height = parent.offsetHeight;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
if (!width || !height) {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
}
|
||||
|
||||
width = Math.max(width, 300);
|
||||
height = Math.max(height, 300);
|
||||
|
||||
renderer!.setSize(width, height);
|
||||
camera.perspective({ aspect: width / height });
|
||||
|
||||
if (mesh) {
|
||||
const distance = camera.position.z;
|
||||
const fov = camera.fov * (Math.PI / 180);
|
||||
const height2 = 2 * Math.tan(fov / 2) * distance;
|
||||
const width2 = height2 * (width / height);
|
||||
|
||||
mesh.scale.set(width2, height2, 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const geometry = new Plane(gl, {
|
||||
width: 1,
|
||||
height: 1
|
||||
});
|
||||
|
||||
const colorRGB = hexToNormalizedRGB(props.color);
|
||||
|
||||
program = new Program(gl, {
|
||||
vertex: vertexShader,
|
||||
fragment: fragmentShader,
|
||||
uniforms: {
|
||||
uSpeed: { value: props.speed },
|
||||
uScale: { value: props.scale },
|
||||
uNoiseIntensity: { value: props.noiseIntensity },
|
||||
uColor: { value: colorRGB },
|
||||
uRotation: { value: props.rotation },
|
||||
uTime: { value: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
mesh = new Mesh(gl, { geometry, program });
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
gl.canvas.style.width = '100%';
|
||||
gl.canvas.style.height = '100%';
|
||||
gl.canvas.style.display = 'block';
|
||||
gl.canvas.style.position = 'absolute';
|
||||
gl.canvas.style.top = '0';
|
||||
gl.canvas.style.left = '0';
|
||||
gl.canvas.style.zIndex = '1';
|
||||
|
||||
let lastTime = 0;
|
||||
const update = (t: number) => {
|
||||
animateId = requestAnimationFrame(update);
|
||||
const deltaTime = (t - lastTime) / 1000;
|
||||
lastTime = t;
|
||||
|
||||
if (program && mesh && camera) {
|
||||
program.uniforms.uTime.value += 0.1 * deltaTime;
|
||||
program.uniforms.uSpeed.value = props.speed;
|
||||
program.uniforms.uScale.value = props.scale;
|
||||
program.uniforms.uNoiseIntensity.value = props.noiseIntensity;
|
||||
program.uniforms.uColor.value = hexToNormalizedRGB(props.color);
|
||||
program.uniforms.uRotation.value = props.rotation;
|
||||
renderer!.render({ scene: mesh, camera });
|
||||
}
|
||||
};
|
||||
animateId = requestAnimationFrame(update);
|
||||
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animateId);
|
||||
window.removeEventListener('resize', resize);
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (animateId) {
|
||||
cancelAnimationFrame(animateId);
|
||||
}
|
||||
if (renderer) {
|
||||
const gl = renderer.gl;
|
||||
const container = containerRef.value;
|
||||
if (container && gl.canvas.parentNode === container) {
|
||||
container.removeChild(gl.canvas);
|
||||
}
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
}
|
||||
renderer = null;
|
||||
mesh = null;
|
||||
camera = null;
|
||||
program = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initSilk();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.speed, props.scale, props.color, props.noiseIntensity, props.rotation],
|
||||
() => {}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
:deep(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
display: block !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
</style>
|
||||
197
src/components/vue-bits/SplitText.vue
Normal file
197
src/components/vue-bits/SplitText.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
|
||||
|
||||
type SplitType = 'chars' | 'words' | 'lines' | 'words, chars';
|
||||
type TagName = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
|
||||
type EaseFn = (t: number) => number;
|
||||
|
||||
interface SplitTextElement extends HTMLElement {
|
||||
_rbsplitInstance?: GSAPSplitText;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
ease?: string | EaseFn;
|
||||
splitType?: SplitType;
|
||||
from?: gsap.TweenVars;
|
||||
to?: gsap.TweenVars;
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
tag?: TagName;
|
||||
textAlign?: CSSProperties['textAlign'];
|
||||
onLetterAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
className: '',
|
||||
delay: 50,
|
||||
duration: 1.25,
|
||||
ease: 'power3.out',
|
||||
splitType: 'chars',
|
||||
from: () => ({ opacity: 0, y: 40 }),
|
||||
to: () => ({ opacity: 1, y: 0 }),
|
||||
threshold: 0.1,
|
||||
rootMargin: '-100px',
|
||||
tag: 'p',
|
||||
textAlign: 'center',
|
||||
onLetterAnimationComplete: undefined
|
||||
});
|
||||
|
||||
// Emit so the parent can use @animation-complete in template
|
||||
const emit = defineEmits<{
|
||||
'animation-complete': [];
|
||||
}>();
|
||||
|
||||
const elRef = ref<SplitTextElement | null>(null);
|
||||
const fontsLoaded = ref(false);
|
||||
const animationCompleted = ref(false);
|
||||
|
||||
// Stable callback ref so the animation closure never captures a stale prop value
|
||||
const onCompleteRef = ref(props.onLetterAnimationComplete);
|
||||
watch(
|
||||
() => props.onLetterAnimationComplete,
|
||||
cb => {
|
||||
onCompleteRef.value = cb;
|
||||
}
|
||||
);
|
||||
|
||||
const scrollTriggerStart = computed(() => {
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
|
||||
const mv = mm ? parseFloat(mm[1]) : 0;
|
||||
const mu = mm ? mm[2] || 'px' : 'px';
|
||||
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
|
||||
return `top ${startPct}%${sign}`;
|
||||
});
|
||||
|
||||
const elStyle = computed<CSSProperties>(() => ({
|
||||
textAlign: props.textAlign,
|
||||
wordWrap: 'break-word',
|
||||
willChange: 'transform, opacity'
|
||||
}));
|
||||
|
||||
// className prop is placed last so user styles always win over base styles
|
||||
const elClass = computed(() =>
|
||||
['split-parent overflow-hidden inline-block whitespace-normal', props.className].filter(Boolean).join(' ')
|
||||
);
|
||||
|
||||
function assignTargets(self: GSAPSplitText): Element[] {
|
||||
if (props.splitType.includes('chars') && self.chars?.length) return self.chars;
|
||||
if (props.splitType.includes('words') && self.words?.length) return self.words;
|
||||
if (props.splitType.includes('lines') && self.lines?.length) return self.lines;
|
||||
return self.chars ?? self.words ?? self.lines ?? [];
|
||||
}
|
||||
|
||||
function destroyInstance(): void {
|
||||
const el = elRef.value;
|
||||
if (!el) return;
|
||||
ScrollTrigger.getAll().forEach(st => {
|
||||
if (st.trigger === el) st.kill();
|
||||
});
|
||||
try {
|
||||
el._rbsplitInstance?.revert();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el._rbsplitInstance = undefined;
|
||||
}
|
||||
|
||||
function initAnimation(): void {
|
||||
const el = elRef.value;
|
||||
if (!el || !props.text || !fontsLoaded.value) return;
|
||||
if (animationCompleted.value) return;
|
||||
|
||||
destroyInstance();
|
||||
|
||||
const start = scrollTriggerStart.value;
|
||||
|
||||
const splitInstance = new GSAPSplitText(el, {
|
||||
type: props.splitType,
|
||||
smartWrap: true,
|
||||
autoSplit: props.splitType === 'lines',
|
||||
linesClass: 'split-line mt-3',
|
||||
wordsClass: 'split-word',
|
||||
charsClass: 'split-char',
|
||||
reduceWhiteSpace: false,
|
||||
onSplit: (self: GSAPSplitText) => {
|
||||
const targets = assignTargets(self);
|
||||
return gsap.fromTo(
|
||||
targets,
|
||||
{ ...props.from },
|
||||
{
|
||||
...props.to,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
stagger: props.delay / 1000,
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start,
|
||||
once: true,
|
||||
fastScrollEnd: true,
|
||||
anticipatePin: 0.4
|
||||
},
|
||||
onComplete: () => {
|
||||
animationCompleted.value = true;
|
||||
onCompleteRef.value?.();
|
||||
emit('animation-complete');
|
||||
},
|
||||
willChange: 'transform, opacity',
|
||||
force3D: true
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
el._rbsplitInstance = splitInstance;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (document.fonts.status === 'loaded') {
|
||||
fontsLoaded.value = true;
|
||||
} else {
|
||||
document.fonts.ready.then(() => {
|
||||
fontsLoaded.value = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(fontsLoaded, loaded => {
|
||||
if (loaded) initAnimation();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.text,
|
||||
props.delay,
|
||||
props.duration,
|
||||
props.ease,
|
||||
props.splitType,
|
||||
JSON.stringify(props.from),
|
||||
JSON.stringify(props.to),
|
||||
props.threshold,
|
||||
props.rootMargin,
|
||||
fontsLoaded.value
|
||||
],
|
||||
() => {
|
||||
if (!fontsLoaded.value) return;
|
||||
animationCompleted.value = false;
|
||||
initAnimation();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyInstance();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="tag" ref="elRef" :class="elClass" :style="elStyle">{{ text }}</component>
|
||||
</template>
|
||||
@@ -1,32 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import ThemeBackdrop from '@/components/ThemeBackdrop.vue'
|
||||
import { siteConfig } from '@/site.config'
|
||||
|
||||
const route = useRoute()
|
||||
const backdropClass = computed(() => route.path.startsWith('/product') ? 'product-route-backdrop' : 'home-route-backdrop')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo -->
|
||||
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold">My App</span>
|
||||
</div>
|
||||
<div class="min-h-screen bg-app text-app">
|
||||
<SiteHeader />
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="hidden md:flex space-x-4">
|
||||
<a href="#" class="hover:text-gray-300">首页</a>
|
||||
<a href="#" class="hover:text-gray-300">About</a>
|
||||
<a href="#" class="hover:text-gray-300">Services</a>
|
||||
<a href="#" class="hover:text-gray-300">Contact</a>
|
||||
<ThemeBackdrop :class="backdropClass">
|
||||
<RouterView />
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer-inner">
|
||||
<div class="site-footer-brand">
|
||||
<strong>{{ siteConfig.brand.name }}</strong>
|
||||
<span>© 2026 萧康云医科技有限公司 版权所有</span>
|
||||
</div>
|
||||
<nav aria-label="底部导航">
|
||||
<RouterLink to="/">首页</RouterLink>
|
||||
<RouterLink to="/product">产品</RouterLink>
|
||||
<RouterLink to="/#business">业务介绍</RouterLink>
|
||||
<RouterLink to="/product#pricing">定价计划</RouterLink>
|
||||
<RouterLink to="/product#contact">联系咨询</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||
Personal Center
|
||||
</button>
|
||||
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
|
||||
<div class="site-footer-records">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">沪ICP备2026009618号-1</a>
|
||||
<span>站点地图</span>
|
||||
<span>隐私政策</span>
|
||||
<span>服务条款</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</ThemeBackdrop>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import MainLayout from '@/layouts/Header.vue'
|
||||
|
||||
// 在路由配置中添加个人中心路由
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
|
||||
]
|
||||
{ path: 'product', name: 'Product', component: () => import('@/views/product/index.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,11 +17,13 @@ const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
if (savedPosition) return savedPosition
|
||||
if (to.hash) return { el: to.hash, top: 96, behavior: 'smooth' }
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
router.beforeEach((to) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
@@ -29,10 +31,3 @@ router.beforeEach((to, from) => {
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
225
src/site.config.js
Normal file
225
src/site.config.js
Normal file
@@ -0,0 +1,225 @@
|
||||
export const siteConfig = {
|
||||
brand: {
|
||||
name: '萧康云医',
|
||||
tagline: '让县域医疗拥有云端协同能力',
|
||||
description: '面向医疗集团、医共体、互联网医院与连锁诊所的一体化智慧医疗 SaaS 平台。'
|
||||
},
|
||||
navigation: {
|
||||
primary: [
|
||||
{ label: '首页', to: '/' },
|
||||
{ label: '产品', to: '/product' }
|
||||
],
|
||||
secondary: {
|
||||
home: [
|
||||
{ label: '业务介绍', to: '/#business' },
|
||||
{ label: '产品简介', to: '/#intro' },
|
||||
{ label: '客户案例', to: '/#cases' },
|
||||
{ label: '联系咨询', to: '/#contact' }
|
||||
],
|
||||
product: [
|
||||
{ label: '功能说明', to: '/product#features' },
|
||||
{ label: '演示视频', to: '/product#demo' },
|
||||
{ label: '定价计划', to: '/product#pricing' },
|
||||
{ label: 'FAQ', to: '/product#faq' },
|
||||
{ label: '联系方式', to: '/product#contact' }
|
||||
]
|
||||
}
|
||||
},
|
||||
hero: {
|
||||
mode: 'video',
|
||||
video: {
|
||||
src: 'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260406_094145_4a271a6c-3869-4f1c-8aa7-aeb0cb227994.mp4',
|
||||
poster: 'https://images.unsplash.com/photo-1576091160550-2173dba999ef?auto=format&fit=crop&w=2200&q=85'
|
||||
}
|
||||
},
|
||||
contact: {
|
||||
phone: '400-820-9618',
|
||||
email: 'hello@xiaokangcloudmed.cn',
|
||||
address: '上海市浦东新区张江医疗创新园'
|
||||
}
|
||||
}
|
||||
|
||||
export const metrics = [
|
||||
{ value: '1200+', label: '接入医疗机构' },
|
||||
{ value: '38%', label: '随访效率提升' },
|
||||
{ value: '99.95%', label: '服务可用性' },
|
||||
{ value: '7x24', label: '医疗级运维响应' }
|
||||
]
|
||||
|
||||
export const homeMarqueeItems = [
|
||||
{
|
||||
title: '区域会诊指挥台',
|
||||
meta: 'Remote Care',
|
||||
image: 'https://images.unsplash.com/photo-1581093458791-9d15482442f6?auto=format&fit=crop&w=900&q=82'
|
||||
},
|
||||
{
|
||||
title: '患者主索引',
|
||||
meta: 'Cloud EMR',
|
||||
image: 'https://images.unsplash.com/photo-1559757148-5c350d0d3c56?auto=format&fit=crop&w=900&q=82'
|
||||
},
|
||||
{
|
||||
title: '慢病风险雷达',
|
||||
meta: 'AI Alert',
|
||||
image: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?auto=format&fit=crop&w=900&q=82'
|
||||
},
|
||||
{
|
||||
title: '移动随访工作流',
|
||||
meta: 'Follow-up',
|
||||
image: 'https://images.unsplash.com/photo-1576671081837-49000212a370?auto=format&fit=crop&w=900&q=82'
|
||||
},
|
||||
{
|
||||
title: '质控运营驾驶舱',
|
||||
meta: 'Operations',
|
||||
image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=82'
|
||||
},
|
||||
{
|
||||
title: '院外服务触达',
|
||||
meta: 'Patient App',
|
||||
image: 'https://images.unsplash.com/photo-1579684385127-1ef15d508118?auto=format&fit=crop&w=900&q=82'
|
||||
}
|
||||
]
|
||||
|
||||
export const homeServiceItems = [
|
||||
{
|
||||
number: '01',
|
||||
name: '云诊疗协同',
|
||||
description: '把转诊、会诊、检查互认和医嘱流转放进同一套跨机构任务流。'
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
name: '慢病连续管理',
|
||||
description: '围绕高血压、糖尿病、康复等场景建立院内院外一体化随访闭环。'
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
name: 'AI 风险预警',
|
||||
description: '结合规则和评分模型,帮助医生优先处理真正需要关注的患者。'
|
||||
},
|
||||
{
|
||||
number: '04',
|
||||
name: '合规权限治理',
|
||||
description: '按机构、科室、角色和数据类型配置访问边界,关键操作留痕可审计。'
|
||||
},
|
||||
{
|
||||
number: '05',
|
||||
name: '运营数据分析',
|
||||
description: '将服务效率、医疗质量和经营指标沉淀为管理层可复盘的视图。'
|
||||
}
|
||||
]
|
||||
|
||||
export const productFeatures = [
|
||||
{
|
||||
title: '云端患者主索引',
|
||||
body: '跨机构合并患者身份、就诊记录、检查检验和随访数据,形成连续健康画像。'
|
||||
},
|
||||
{
|
||||
title: 'AI 辅助分诊与预警',
|
||||
body: '基于规则引擎和模型评分识别高风险患者,优先推送给责任医生处理。'
|
||||
},
|
||||
{
|
||||
title: '医患协同工作台',
|
||||
body: '医生端、护士端、患者端统一任务流,支持在线复诊、处方流转和宣教触达。'
|
||||
},
|
||||
{
|
||||
title: '合规与权限治理',
|
||||
body: '按机构、科室、角色和数据类型配置访问边界,满足医疗数据安全要求。'
|
||||
}
|
||||
]
|
||||
|
||||
export const caseStudies = [
|
||||
{
|
||||
client: '华东县域医共体',
|
||||
result: '6 个月完成 21 家基层机构协同上线,转诊平均等待时间下降 31%。',
|
||||
quote: '萧康云医让基层医生能实时看到患者全周期信息,协同不再依赖电话和表格。'
|
||||
},
|
||||
{
|
||||
client: '连锁康复诊所集团',
|
||||
result: '慢病随访自动化后,护士团队日均处理量提升 44%。',
|
||||
quote: '它把复诊、随访、宣教和经营数据放到同一套流程里,管理颗粒度明显变细。'
|
||||
},
|
||||
{
|
||||
client: '省级互联网医院项目',
|
||||
result: '支撑高峰期 18 万次在线问诊请求,服务稳定性保持 99.95%。',
|
||||
quote: '平台架构足够稳,产品团队也懂医疗现场,这是我们选择它的关键。'
|
||||
}
|
||||
]
|
||||
|
||||
export const homeCaseCards = [
|
||||
{
|
||||
number: '01',
|
||||
category: '医共体',
|
||||
name: '县域协同平台',
|
||||
summary: '21 家基层机构完成统一协同上线,转诊等待时间下降 31%。',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1584982751601-97dcc096659c?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1586773860418-d37222d8fce3?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1579154204601-01588f351e67?auto=format&fit=crop&w=1200&q=82'
|
||||
]
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
category: '连锁诊所',
|
||||
name: '慢病随访中心',
|
||||
summary: '护士团队日均处理量提升 44%,患者院外触达更稳定。',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1571772996211-2f02c9727629?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1584515933487-779824d29309?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1563986768609-322da13575f3?auto=format&fit=crop&w=1200&q=82'
|
||||
]
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
category: '互联网医院',
|
||||
name: '线上诊疗中台',
|
||||
summary: '高峰期支撑 18 万次在线问诊请求,服务可用性保持 99.95%。',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1581056771107-24ca5f033842?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1583912267550-d44c6c1cf30f?auto=format&fit=crop&w=900&q=82',
|
||||
'https://images.unsplash.com/photo-1551434678-e076c223a692?auto=format&fit=crop&w=1200&q=82'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const pricingPlans = [
|
||||
{
|
||||
name: '标准版',
|
||||
price: '¥6,800',
|
||||
unit: '机构 / 月',
|
||||
description: '适合单体医院、门诊部或成长型诊所。',
|
||||
features: ['患者档案云管理', '在线复诊与随访', '基础运营报表', '标准接口支持']
|
||||
},
|
||||
{
|
||||
name: '专业版',
|
||||
price: '¥18,000',
|
||||
unit: '医疗集团 / 月',
|
||||
description: '适合多院区协同、连锁机构与医共体。',
|
||||
features: ['区域协同工作台', 'AI 风险预警', '高级权限治理', '专属实施顾问'],
|
||||
highlighted: true
|
||||
},
|
||||
{
|
||||
name: '旗舰版',
|
||||
price: '定制',
|
||||
unit: '私有化 / 混合云',
|
||||
description: '适合大型医疗集团、政府平台与高合规场景。',
|
||||
features: ['私有化部署', '数据中台集成', '定制模型与流程', 'SLA 与驻场支持']
|
||||
}
|
||||
]
|
||||
|
||||
export const faqs = [
|
||||
{
|
||||
q: '萧康云医适合哪些机构?',
|
||||
a: '适合医疗集团、医共体、基层医院、互联网医院、连锁诊所和需要长期患者管理的专科机构。'
|
||||
},
|
||||
{
|
||||
q: '能否接入现有 HIS、EMR 或支付系统?',
|
||||
a: '可以。平台提供标准 API 与实施接口规范,常见 HIS、EMR、LIS、PACS、医保和支付系统都可对接。'
|
||||
},
|
||||
{
|
||||
q: '数据安全如何保障?',
|
||||
a: '平台支持分级权限、操作审计、传输加密、备份策略和私有化部署方案,可按项目合规要求配置。'
|
||||
},
|
||||
{
|
||||
q: '上线周期通常多久?',
|
||||
a: '标准版通常 2-4 周完成上线;多机构协同项目会根据接口数量、流程复杂度和培训范围排期。'
|
||||
}
|
||||
]
|
||||
3757
src/style.css
3757
src/style.css
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, ArrowRight, Calendar, CheckCircle2, Clock3, Play, ShieldCheck, Sparkles, Star } from 'lucide-vue-next'
|
||||
import { homeCaseCards, homeMarqueeItems, homeServiceItems, metrics, siteConfig } from '@/site.config'
|
||||
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
|
||||
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
|
||||
import Magnet from '@/components/vue-bits/Magnet.vue'
|
||||
import Silk from '@/components/vue-bits/Silk.vue'
|
||||
import SplitText from '@/components/vue-bits/SplitText.vue'
|
||||
|
||||
const aboutRef = ref(null)
|
||||
const aboutProgress = ref(0)
|
||||
|
||||
const aboutSegments = [
|
||||
'萧康云医把患者、医生、机构和运营管理连接成同一个实时协作系统。',
|
||||
'我们服务医共体、医疗集团、互联网医院与连锁诊所,',
|
||||
'让复杂的医疗流程在云端变得更轻、更稳、更可信。'
|
||||
]
|
||||
|
||||
const marqueeRowOne = computed(() => homeMarqueeItems.slice(0, 3))
|
||||
const marqueeRowTwo = computed(() => homeMarqueeItems.slice(3))
|
||||
const repeatedRowOne = computed(() => [...marqueeRowOne.value, ...marqueeRowOne.value, ...marqueeRowOne.value])
|
||||
const repeatedRowTwo = computed(() => [...marqueeRowTwo.value, ...marqueeRowTwo.value, ...marqueeRowTwo.value])
|
||||
|
||||
let ticking = false
|
||||
let reduceMotion = false
|
||||
|
||||
function updateScrollMotion() {
|
||||
ticking = false
|
||||
if (reduceMotion) return
|
||||
|
||||
if (aboutRef.value) {
|
||||
const rect = aboutRef.value.getBoundingClientRect()
|
||||
const distance = window.innerHeight * 0.78 - rect.top
|
||||
const range = rect.height + window.innerHeight * 0.2
|
||||
aboutProgress.value = Math.min(1, Math.max(0, distance / range))
|
||||
}
|
||||
}
|
||||
|
||||
function requestScrollUpdate() {
|
||||
if (ticking) return
|
||||
ticking = true
|
||||
window.requestAnimationFrame(updateScrollMotion)
|
||||
}
|
||||
|
||||
function segmentOpacity(index) {
|
||||
if (reduceMotion) return 1
|
||||
const start = index / aboutSegments.length
|
||||
const progress = (aboutProgress.value - start) * 2.6
|
||||
return Math.min(1, Math.max(0.5, progress))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
updateScrollMotion()
|
||||
window.addEventListener('scroll', requestScrollUpdate, { passive: true })
|
||||
window.addEventListener('resize', requestScrollUpdate)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', requestScrollUpdate)
|
||||
window.removeEventListener('resize', requestScrollUpdate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
你好
|
||||
<main class="home-page">
|
||||
<section class="cinematic-hero">
|
||||
<video
|
||||
class="cinematic-video"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="siteConfig.hero.video.poster"
|
||||
>
|
||||
<source :src="siteConfig.hero.video.src" type="video/mp4">
|
||||
</video>
|
||||
<div class="hero-silk-layer" aria-hidden="true">
|
||||
<Silk color="#14B8A6" :speed="2.6" :scale="1.2" :noise-intensity="0.78" :rotation="0.16" />
|
||||
</div>
|
||||
<div class="bottom-blur-overlay" aria-hidden="true"></div>
|
||||
|
||||
<div class="cinematic-content">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-meta animate-blur-fade-up" style="animation-delay: 300ms">
|
||||
<span>
|
||||
<Star :size="16" fill="currentColor" aria-hidden="true" />
|
||||
99.95% SLA
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 :size="16" aria-hidden="true" />
|
||||
7x24 医疗级运维
|
||||
</span>
|
||||
<span>
|
||||
<Calendar :size="16" aria-hidden="true" />
|
||||
2026 云医协同平台
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SplitText
|
||||
:text="siteConfig.brand.name"
|
||||
tag="h1"
|
||||
class-name="cinematic-title"
|
||||
split-type="chars"
|
||||
:delay="80"
|
||||
:duration="1.15"
|
||||
ease="power4.out"
|
||||
:threshold="0.05"
|
||||
root-margin="0px"
|
||||
:from="{ opacity: 0, y: 70, filter: 'blur(18px)', rotateX: -22 }"
|
||||
:to="{ opacity: 1, y: 0, filter: 'blur(0px)', rotateX: 0 }"
|
||||
text-align="left"
|
||||
/>
|
||||
<p class="cinematic-subtitle animate-blur-fade-up" style="animation-delay: 500ms">
|
||||
<DecryptedText
|
||||
text="Step Through. Work Smarter."
|
||||
animate-on="view"
|
||||
reveal-direction="center"
|
||||
:speed="34"
|
||||
:max-iterations="12"
|
||||
encrypted-class-name="decrypted-ghost"
|
||||
class-name="decrypted-final"
|
||||
/>
|
||||
<span> 让区域诊疗、慢病随访、远程会诊和运营治理在云端自然协作。</span>
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<Magnet :padding="80" :magnet-strength="5" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 600ms">
|
||||
<RouterLink to="/product#contact" class="watch-button">
|
||||
<Play :size="18" fill="currentColor" aria-hidden="true" />
|
||||
免费试用
|
||||
</RouterLink>
|
||||
</Magnet>
|
||||
<Magnet :padding="72" :magnet-strength="5.5" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 700ms">
|
||||
<RouterLink to="/product#demo" class="learn-button liquid-glass">
|
||||
观看演示
|
||||
</RouterLink>
|
||||
</Magnet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-arrows">
|
||||
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 800ms">
|
||||
<RouterLink to="/#business" class="arrow-pill liquid-glass">
|
||||
<ArrowLeft :size="18" aria-hidden="true" />
|
||||
业务介绍
|
||||
</RouterLink>
|
||||
</Magnet>
|
||||
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 900ms">
|
||||
<RouterLink to="/product" class="arrow-pill liquid-glass">
|
||||
产品页
|
||||
<ArrowRight :size="18" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
</Magnet>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="business" class="home-marquee-section">
|
||||
<AnimatedContent class-name="marquee-heading" :distance="44" :duration="0.9" ease="power3.out">
|
||||
<span>Business Flow</span>
|
||||
<h2 class="mega-heading home-heading-cn">云端医疗协同现场</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="marquee-rows" aria-label="萧康云医业务场景">
|
||||
<div class="marquee-track marquee-track-forward">
|
||||
<article v-for="(item, index) in repeatedRowOne" :key="`${item.title}-top-${index}`" class="marquee-card glare-card">
|
||||
<img :src="item.image" :alt="item.title" loading="lazy">
|
||||
<div>
|
||||
<span>{{ item.meta }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="marquee-track marquee-track-reverse">
|
||||
<article v-for="(item, index) in repeatedRowTwo" :key="`${item.title}-bottom-${index}`" class="marquee-card glare-card">
|
||||
<img :src="item.image" :alt="item.title" loading="lazy">
|
||||
<div>
|
||||
<span>{{ item.meta }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="intro" ref="aboutRef" class="creator-about-section">
|
||||
<div class="floating-medical-chip chip-a">
|
||||
<Sparkles :size="20" aria-hidden="true" />
|
||||
AI Alert
|
||||
</div>
|
||||
<div class="floating-medical-chip chip-b">
|
||||
<ShieldCheck :size="20" aria-hidden="true" />
|
||||
Data Safe
|
||||
</div>
|
||||
<div class="floating-medical-chip chip-c">
|
||||
<CheckCircle2 :size="20" aria-hidden="true" />
|
||||
Follow-up
|
||||
</div>
|
||||
<div class="floating-medical-chip chip-d">
|
||||
<Star :size="20" aria-hidden="true" />
|
||||
99.95%
|
||||
</div>
|
||||
|
||||
<AnimatedContent class-name="about-inner" :distance="52" :duration="1" ease="power4.out">
|
||||
<SplitText
|
||||
text="关于萧康云医"
|
||||
tag="h2"
|
||||
class-name="mega-heading home-heading-cn"
|
||||
split-type="lines"
|
||||
:delay="80"
|
||||
:duration="1"
|
||||
ease="power3.out"
|
||||
:from="{ opacity: 0, y: 34, filter: 'blur(12px)' }"
|
||||
:to="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
|
||||
/>
|
||||
<p class="animated-copy" aria-label="萧康云医介绍">
|
||||
<span
|
||||
v-for="(segment, index) in aboutSegments"
|
||||
:key="segment"
|
||||
class="about-segment"
|
||||
:style="{ opacity: segmentOpacity(index) }"
|
||||
>{{ segment }}</span>
|
||||
</p>
|
||||
<Magnet :padding="90" :magnet-strength="5">
|
||||
<RouterLink to="/product#contact" class="creator-contact-button">联系顾问</RouterLink>
|
||||
</Magnet>
|
||||
</AnimatedContent>
|
||||
</section>
|
||||
|
||||
<section class="creator-services-section">
|
||||
<SplitText
|
||||
text="Services"
|
||||
tag="h2"
|
||||
class-name="services-heading"
|
||||
split-type="chars"
|
||||
:delay="42"
|
||||
:duration="0.9"
|
||||
ease="power3.out"
|
||||
:from="{ opacity: 0, y: 46, filter: 'blur(12px)' }"
|
||||
:to="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
|
||||
/>
|
||||
<div class="services-list">
|
||||
<AnimatedContent
|
||||
v-for="(item, index) in homeServiceItems"
|
||||
:key="item.number"
|
||||
class-name="service-reveal"
|
||||
:distance="56"
|
||||
direction="horizontal"
|
||||
:reverse="index % 2 === 1"
|
||||
:delay="index * 0.08"
|
||||
:duration="0.85"
|
||||
>
|
||||
<article class="service-item">
|
||||
<span>{{ item.number }}</span>
|
||||
<div>
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cases" class="creator-projects-section">
|
||||
<AnimatedContent class-name="case-section-heading" :distance="48" :duration="0.9">
|
||||
<span>Case Study</span>
|
||||
<h2 class="mega-heading home-heading-cn">客户案例</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="stable-case-list">
|
||||
<article
|
||||
v-for="(item, index) in homeCaseCards"
|
||||
:key="item.name"
|
||||
class="stable-case-card"
|
||||
:style="{ top: `${104 + index * 18}px` }"
|
||||
>
|
||||
<div class="case-card-head">
|
||||
<span>{{ item.number }}</span>
|
||||
<div class="case-card-copy">
|
||||
<small>{{ item.category }}</small>
|
||||
<h3>{{ item.name }}</h3>
|
||||
<p>{{ item.summary }}</p>
|
||||
</div>
|
||||
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="case-card-action">
|
||||
<RouterLink to="/product#contact" class="live-project-button">预约演示</RouterLink>
|
||||
</Magnet>
|
||||
</div>
|
||||
|
||||
<div class="case-image-grid">
|
||||
<div>
|
||||
<img :src="item.images[0]" :alt="`${item.name} 业务图一`" loading="lazy">
|
||||
<img :src="item.images[1]" :alt="`${item.name} 业务图二`" loading="lazy">
|
||||
</div>
|
||||
<img :src="item.images[2]" :alt="`${item.name} 场景图`" loading="lazy">
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="home-contact-section">
|
||||
<AnimatedContent class-name="home-contact-copy" :distance="50" :duration="0.9">
|
||||
<span>Contact</span>
|
||||
<h2 class="mega-heading home-heading-cn">让医疗协同真正跑起来</h2>
|
||||
<p>告诉我们你的机构规模、系统现状和重点场景,萧康云医团队会给出一份可落地的演示方案。</p>
|
||||
<Magnet :padding="90" :magnet-strength="5" wrapper-class-name="contact-action">
|
||||
<RouterLink to="/product#contact" class="creator-contact-button">立即咨询</RouterLink>
|
||||
</Magnet>
|
||||
</AnimatedContent>
|
||||
|
||||
<AnimatedContent class-name="contact-metrics" :distance="50" direction="horizontal" :reverse="true" :duration="0.9">
|
||||
<div v-for="item in metrics" :key="item.label">
|
||||
<strong>{{ item.value }}</strong>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
326
src/views/product/index.vue
Normal file
326
src/views/product/index.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ArrowRight, BadgeCheck, Check, CircleDollarSign, ClipboardCheck, Headphones, MessageCircle, Play, ShieldCheck, Star, Video } from 'lucide-vue-next'
|
||||
import { caseStudies, faqs, pricingPlans, productFeatures, siteConfig } from '@/site.config'
|
||||
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
|
||||
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
|
||||
import Magnet from '@/components/vue-bits/Magnet.vue'
|
||||
|
||||
const openFaq = ref(0)
|
||||
|
||||
const users = [
|
||||
'医疗集团信息化负责人',
|
||||
'医共体运营管理者',
|
||||
'慢病中心与专科门诊',
|
||||
'互联网医院项目团队'
|
||||
]
|
||||
|
||||
const reviews = [
|
||||
{
|
||||
name: '林主任',
|
||||
role: '县域医共体信息中心',
|
||||
text: '上线后跨院区协作终于有了统一任务流,基层医生和总院专家都能在同一视图处理患者。'
|
||||
},
|
||||
{
|
||||
name: '周女士',
|
||||
role: '连锁诊所运营负责人',
|
||||
text: '随访、复诊和宣教不再分散在不同工具里,护士团队的工作量和服务质量都更可控。'
|
||||
},
|
||||
{
|
||||
name: '赵医生',
|
||||
role: '慢病中心负责人',
|
||||
text: '风险预警和患者画像帮助我们更早发现异常,医生不再只靠经验筛选重点患者。'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="product-page">
|
||||
<section class="product-hero section-pad">
|
||||
<div class="mx-auto grid max-w-7xl items-center gap-12 px-5 pt-24 md:grid-cols-[0.95fr_1.05fr] md:px-8">
|
||||
<AnimatedContent :distance="64" :duration="0.95" class-name="product-hero-copy">
|
||||
<div class="eyebrow">
|
||||
<BadgeCheck :size="16" aria-hidden="true" />
|
||||
企业级医疗 SaaS 产品
|
||||
</div>
|
||||
<h1 class="product-hero-title">
|
||||
<span>{{ siteConfig.brand.name }}</span>
|
||||
<span>云协同平台</span>
|
||||
</h1>
|
||||
<p class="product-hero-copytext">
|
||||
<DecryptedText
|
||||
text="一套连接患者、医生、机构和运营管理的智慧医疗工作台。"
|
||||
animate-on="view"
|
||||
reveal-direction="center"
|
||||
:speed="32"
|
||||
:max-iterations="10"
|
||||
encrypted-class-name="decrypted-ghost"
|
||||
class-name="decrypted-final"
|
||||
/>
|
||||
<span> 让医疗组织拥有更稳定、更高效、更合规的数字化服务能力。</span>
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Magnet :padding="72" :magnet-strength="5.5">
|
||||
<a href="#contact" class="btn-primary">
|
||||
免费试用
|
||||
<ArrowRight :size="18" aria-hidden="true" />
|
||||
</a>
|
||||
</Magnet>
|
||||
<Magnet :padding="72" :magnet-strength="5.5">
|
||||
<a href="#demo" class="btn-secondary">查看演示视频</a>
|
||||
</Magnet>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
|
||||
<AnimatedContent :distance="72" direction="horizontal" :reverse="true" :duration="0.95" class-name="product-snapshot-shell">
|
||||
<div class="product-snapshot">
|
||||
<div class="snapshot-header">
|
||||
<span>萧康云医工作台</span>
|
||||
<strong>实时运行</strong>
|
||||
</div>
|
||||
<div class="snapshot-main">
|
||||
<aside>
|
||||
<span v-for="item in ['患者画像', '协同任务', '风险预警', '运营分析']" :key="item">{{ item }}</span>
|
||||
</aside>
|
||||
<div class="snapshot-content">
|
||||
<div class="snapshot-alert">
|
||||
<ShieldCheck :size="20" aria-hidden="true" />
|
||||
<span>慢病高风险患者已自动分派给责任医生</span>
|
||||
</div>
|
||||
<div class="snapshot-cards">
|
||||
<div>
|
||||
<small>待处理随访</small>
|
||||
<strong>246</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>会诊申请</small>
|
||||
<strong>38</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>质控通过率</small>
|
||||
<strong>97.8%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-bars" aria-hidden="true">
|
||||
<span style="width: 76%"></span>
|
||||
<span style="width: 92%"></span>
|
||||
<span style="width: 58%"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="section-pad pt-8">
|
||||
<div class="mx-auto max-w-7xl px-5 md:px-8">
|
||||
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
|
||||
<span>功能说明</span>
|
||||
<h2>从诊疗协同到经营分析,覆盖医疗服务闭环。</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="mt-10 grid gap-5 md:grid-cols-2">
|
||||
<AnimatedContent v-for="(feature, index) in productFeatures" :key="feature.title" :delay="index * 0.08" :distance="52" class-name="feature-reveal">
|
||||
<article class="soft-card feature-row">
|
||||
<ClipboardCheck :size="26" aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ feature.title }}</h3>
|
||||
<p>{{ feature.body }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
|
||||
<AnimatedContent class-name="target-users mt-8" :distance="38" :duration="0.75">
|
||||
<span>目标用户</span>
|
||||
<div>
|
||||
<span v-for="user in users" :key="user">{{ user }}</span>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="demo" class="section-band">
|
||||
<div class="mx-auto grid max-w-7xl items-center gap-10 px-5 md:grid-cols-[1.08fr_0.92fr] md:px-8">
|
||||
<AnimatedContent :distance="62" :duration="1" class-name="demo-frame-shell">
|
||||
<div class="demo-frame">
|
||||
<video
|
||||
class="demo-video"
|
||||
controls
|
||||
:poster="siteConfig.hero.video.poster"
|
||||
>
|
||||
<source :src="siteConfig.hero.video.src" type="video/mp4">
|
||||
</video>
|
||||
<div class="demo-play" aria-hidden="true">
|
||||
<Play :size="28" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
|
||||
<AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="demo-copy-shell">
|
||||
<div>
|
||||
<div class="section-heading align-left">
|
||||
<span>演示视频</span>
|
||||
<h2>用 90 秒看清医生、护士和管理者如何协同。</h2>
|
||||
</div>
|
||||
<p class="mt-5 text-lg leading-8 text-muted">
|
||||
演示覆盖患者建档、风险预警、复诊任务、随访触达和运营看板。视频源可在配置文件中替换为企业真实素材。
|
||||
</p>
|
||||
<div class="mt-6 grid gap-3">
|
||||
<div class="check-line">
|
||||
<Video :size="18" aria-hidden="true" />
|
||||
支持产品界面录屏、品牌宣传片或客户案例视频
|
||||
</div>
|
||||
<div class="check-line">
|
||||
<Check :size="18" aria-hidden="true" />
|
||||
首页同样支持动效背景与视频背景两种配置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad">
|
||||
<div class="mx-auto max-w-7xl px-5 md:px-8">
|
||||
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
|
||||
<span>客户案例</span>
|
||||
<h2>已经在多机构协同、慢病管理和互联网医院场景中验证。</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="mt-10 grid gap-5 lg:grid-cols-3">
|
||||
<AnimatedContent v-for="(item, index) in caseStudies" :key="item.client" :delay="index * 0.08" :distance="52" class-name="case-reveal">
|
||||
<article class="case-card">
|
||||
<span>{{ item.client }}</span>
|
||||
<h3>{{ item.result }}</h3>
|
||||
<p>{{ item.quote }}</p>
|
||||
</article>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pricing" class="section-band">
|
||||
<div class="mx-auto max-w-7xl px-5 md:px-8">
|
||||
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
|
||||
<span>定价计划</span>
|
||||
<h2>按机构规模和部署要求选择服务方案。</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="mt-10 grid gap-5 lg:grid-cols-3">
|
||||
<AnimatedContent v-for="(plan, index) in pricingPlans" :key="plan.name" :delay="index * 0.08" :distance="52" class-name="pricing-reveal">
|
||||
<article
|
||||
class="pricing-card"
|
||||
:class="{ 'pricing-featured': plan.highlighted }"
|
||||
>
|
||||
<div class="pricing-head">
|
||||
<span>{{ plan.name }}</span>
|
||||
<CircleDollarSign :size="24" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<strong>{{ plan.price }}</strong>
|
||||
<small>{{ plan.unit }}</small>
|
||||
</div>
|
||||
<p>{{ plan.description }}</p>
|
||||
<ul>
|
||||
<li v-for="feature in plan.features" :key="feature">
|
||||
<Check :size="17" aria-hidden="true" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
<a href="#contact" class="pricing-button">{{ plan.highlighted ? '预约方案顾问' : '开始咨询' }}</a>
|
||||
</article>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-pad">
|
||||
<div class="mx-auto max-w-7xl px-5 md:px-8">
|
||||
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
|
||||
<span>用户评论与社会证明</span>
|
||||
<h2>来自真实医疗场景的反馈。</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<div class="mt-10 grid gap-5 md:grid-cols-3">
|
||||
<AnimatedContent v-for="(review, index) in reviews" :key="review.name" :delay="index * 0.08" :distance="52" class-name="review-reveal">
|
||||
<article class="review-card">
|
||||
<div class="stars" aria-label="五星评价">
|
||||
<Star v-for="star in 5" :key="star" :size="16" fill="currentColor" aria-hidden="true" />
|
||||
</div>
|
||||
<p>{{ review.text }}</p>
|
||||
<div>
|
||||
<strong>{{ review.name }}</strong>
|
||||
<span>{{ review.role }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="faq" class="section-band">
|
||||
<div class="mx-auto grid max-w-7xl gap-10 px-5 md:grid-cols-[0.85fr_1.15fr] md:px-8">
|
||||
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading align-left">
|
||||
<span>FAQ</span>
|
||||
<h2>常见问题</h2>
|
||||
</AnimatedContent>
|
||||
|
||||
<AnimatedContent class-name="faq-list" :distance="54" direction="horizontal" :reverse="true" :duration="0.9">
|
||||
<button
|
||||
v-for="(item, index) in faqs"
|
||||
:key="item.q"
|
||||
type="button"
|
||||
class="faq-item"
|
||||
:aria-expanded="openFaq === index"
|
||||
@click="openFaq = openFaq === index ? -1 : index"
|
||||
>
|
||||
<span>{{ item.q }}</span>
|
||||
<p v-if="openFaq === index">{{ item.a }}</p>
|
||||
</button>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="section-pad">
|
||||
<div class="mx-auto grid max-w-7xl gap-8 px-5 md:grid-cols-[0.9fr_1.1fr] md:px-8">
|
||||
<AnimatedContent :distance="56" :duration="0.95" class-name="contact-panel">
|
||||
<Headphones :size="30" aria-hidden="true" />
|
||||
<h2>联系萧康云医</h2>
|
||||
<p>留下需求后,产品顾问会在 1 个工作日内联系你,也可以直接通过电话或邮件咨询。</p>
|
||||
<div class="contact-lines">
|
||||
<span>{{ siteConfig.contact.phone }}</span>
|
||||
<span>{{ siteConfig.contact.email }}</span>
|
||||
<span>{{ siteConfig.contact.address }}</span>
|
||||
</div>
|
||||
</AnimatedContent>
|
||||
|
||||
<AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="contact-form-shell">
|
||||
<form class="contact-form" aria-label="预约演示表单">
|
||||
<label>
|
||||
姓名
|
||||
<input type="text" placeholder="请输入姓名">
|
||||
</label>
|
||||
<label>
|
||||
机构名称
|
||||
<input type="text" placeholder="医院、集团或诊所名称">
|
||||
</label>
|
||||
<label>
|
||||
联系方式
|
||||
<input type="tel" placeholder="手机号或邮箱">
|
||||
</label>
|
||||
<label>
|
||||
关注场景
|
||||
<textarea rows="4" placeholder="例如医共体协同、慢病随访、互联网医院建设"></textarea>
|
||||
</label>
|
||||
<button type="button" class="btn-primary justify-center">
|
||||
提交预约
|
||||
<MessageCircle :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
</AnimatedContent>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user