feat: add typing carousel with promotional messages

- 5 rotating promotional commands with typing effect
- Progress bar and navigation dots
- Messages: instant deploy, DDoS protection, pricing, Telegram bot, regions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-30 07:48:03 +09:00
parent 05b8cf5e3a
commit cec96bc3b2

View File

@@ -137,22 +137,44 @@
</p> </p>
</div> </div>
<!-- Interactive Command --> <!-- Typing Carousel -->
<div class="w-full max-w-2xl mt-6 group"> <div class="w-full max-w-3xl mt-6" x-data="typingCarousel()">
<div class="bg-terminal-bg border border-terminal-border rounded flex items-center p-3 md:p-4 gap-3 shadow-lg group-hover:border-primary/50 transition-colors"> <div class="bg-terminal-bg border border-terminal-border rounded-lg overflow-hidden shadow-lg">
<span class="text-primary font-bold select-none">$</span> <!-- Terminal Header -->
<div class="flex-1 text-sm md:text-base flex items-center gap-2 overflow-x-auto"> <div class="bg-[#161b22] px-4 py-2 border-b border-terminal-border flex items-center gap-2">
<span class="text-terminal-purple">incus</span> <span class="text-terminal-muted text-xs">anvil-cli v2.4.0</span>
<span class="text-terminal-text">launch</span> <span class="ml-auto text-xs text-terminal-muted" x-text="(currentIndex + 1) + '/' + commands.length"></span>
<span class="text-terminal-cyan">ubuntu:24.04</span> </div>
<span class="text-terminal-amber">tokyo-1</span> <!-- Command Display -->
<span class="text-terminal-muted">--vm</span> <div class="p-4 md:p-6 min-h-[120px]">
<div class="flex items-start gap-3">
<span class="text-primary font-bold select-none">$</span>
<div class="flex-1">
<div class="text-sm md:text-base flex flex-wrap items-center gap-1">
<span class="text-terminal-text" x-html="displayedCommand"></span>
<span class="text-primary cursor-blink text-lg" x-show="isTyping"></span>
</div>
<!-- Result Message -->
<div class="mt-4 text-terminal-muted text-sm border-l-2 border-primary/50 pl-3 transition-opacity duration-300"
:class="showResult ? 'opacity-100' : 'opacity-0'">
<span class="text-primary"></span>
<span x-text="commands[currentIndex]?.result"></span>
</div>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="h-1 bg-terminal-border">
<div class="h-full bg-primary transition-all duration-100 ease-linear" :style="'width: ' + progress + '%'"></div>
</div> </div>
<span class="text-xs text-terminal-muted bg-terminal-border/30 px-2 py-1 rounded hidden sm:inline-block">ENTER ↵</span>
</div> </div>
<div class="pl-4 mt-2 text-xs text-terminal-muted flex items-center gap-2"> <!-- Navigation Dots -->
<span class="w-2 h-2 rounded-full bg-primary animate-pulse"></span> <div class="flex justify-center gap-2 mt-4">
Instance creating... (0.4s) <template x-for="(cmd, idx) in commands" :key="idx">
<button @click="goTo(idx)"
class="w-2 h-2 rounded-full transition-all"
:class="idx === currentIndex ? 'bg-primary w-6' : 'bg-terminal-border hover:bg-terminal-muted'"></button>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -353,6 +375,128 @@
<!-- Alpine.js --> <!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
<script> <script>
function typingCarousel() {
return {
commands: [
{
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">deploy</span> <span class="text-terminal-cyan">--tokyo</span> <span class="text-terminal-amber">--instant</span>',
plain: 'anvil deploy --tokyo --instant',
result: '0.4초 만에 서버 생성 완료. VM 부팅 대기? 그런 건 없습니다.'
},
{
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">protect</span> <span class="text-terminal-cyan">--ddos</span> <span class="text-terminal-amber">1Tbps+</span>',
plain: 'anvil protect --ddos 1Tbps+',
result: 'ForgeShield 무료 제공. DDoS 걱정은 이제 그만.'
},
{
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">pricing</span> <span class="text-terminal-cyan">--monthly</span> <span class="text-terminal-amber">₩7,000~</span>',
plain: 'anvil pricing --monthly ₩7,000~',
result: '커피 두 잔 값으로 나만의 서버를. 연간 결제 시 17% 할인.'
},
{
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">connect</span> <span class="text-terminal-cyan">@AnvilForgeBot</span>',
plain: 'anvil connect @AnvilForgeBot',
result: '텔레그램으로 서버 관리. 24시간 어디서든 컨트롤.'
},
{
text: '<span class="text-terminal-purple">anvil</span> <span class="text-terminal-text">regions</span> <span class="text-terminal-cyan">--list</span>',
plain: 'anvil regions --list',
result: '도쿄 · 오사카 · 서울 · 싱가포르. 아시아 전역 커버.'
},
],
currentIndex: 0,
displayedCommand: '',
isTyping: true,
showResult: false,
progress: 0,
charIndex: 0,
intervalId: null,
progressId: null,
init() {
this.startTyping();
},
startTyping() {
this.isTyping = true;
this.showResult = false;
this.displayedCommand = '';
this.charIndex = 0;
this.progress = 0;
const plainText = this.commands[this.currentIndex].plain;
const htmlText = this.commands[this.currentIndex].text;
// Clear any existing intervals
if (this.intervalId) clearInterval(this.intervalId);
if (this.progressId) clearInterval(this.progressId);
// Typing effect
this.intervalId = setInterval(() => {
if (this.charIndex < plainText.length) {
this.charIndex++;
// Map plain text position to HTML
this.displayedCommand = this.getPartialHtml(htmlText, this.charIndex);
} else {
clearInterval(this.intervalId);
this.isTyping = false;
this.showResult = true;
this.startProgress();
}
}, 50);
},
getPartialHtml(html, charCount) {
let result = '';
let visibleChars = 0;
let inTag = false;
for (let i = 0; i < html.length && visibleChars < charCount; i++) {
if (html[i] === '<') inTag = true;
if (!inTag) visibleChars++;
result += html[i];
if (html[i] === '>') inTag = false;
}
// Close any open tags
const openTags = result.match(/<span[^>]*>/g) || [];
const closeTags = result.match(/<\/span>/g) || [];
const unclosed = openTags.length - closeTags.length;
for (let i = 0; i < unclosed; i++) {
result += '</span>';
}
return result;
},
startProgress() {
const duration = 4000; // 4 seconds display time
const interval = 50;
const step = (interval / duration) * 100;
this.progressId = setInterval(() => {
this.progress += step;
if (this.progress >= 100) {
clearInterval(this.progressId);
this.next();
}
}, interval);
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.commands.length;
this.startTyping();
},
goTo(index) {
if (this.intervalId) clearInterval(this.intervalId);
if (this.progressId) clearInterval(this.progressId);
this.currentIndex = index;
this.startTyping();
}
};
}
function pricingTerminal() { function pricingTerminal() {
return { return {
// State // State