Files
anvil-hosting/index.html

834 lines
37 KiB
HTML

<!DOCTYPE html>
<html class="dark scroll-smooth" lang="ko">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Anvil Hosting - 엔지니어가 관리하는 Linux 서버</title>
<!-- SEO Meta Tags -->
<meta name="description" content="엔지니어가 직접 튜닝한 Linux 서버. 웹, DB, API, 게임서버 뭐든 돌립니다. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
<meta name="keywords" content="Linux 서버, 클라우드 서버, VPS, 도쿄 서버, 서울 서버, 관리형 호스팅, 서버 호스팅">
<meta name="author" content="Anvil Hosting">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://hosting.inouter.com">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://hosting.inouter.com">
<meta property="og:title" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
<meta property="og:description" content="엔지니어가 직접 튜닝한 Linux 서버. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
<meta property="og:image" content="https://hosting.inouter.com/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
<meta property="og:locale" content="ko_KR">
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Anvil Hosting - 엔지니어가 관리하는 Linux 서버">
<meta name="twitter:description" content="엔지니어가 직접 튜닝한 Linux 서버. 고객의 문제가 해결될 때까지, 우리는 멈추지 않습니다.">
<meta name="twitter:image" content="https://hosting.inouter.com/og-image.png">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Fonts -->
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<!-- Tailwind CSS (prebuilt) -->
<link href="style.css" rel="stylesheet"/>
<style>
/* Custom colors for terminal theme */
:root {
--color-primary: #3fb950;
--color-background-dark: #0d1117;
--color-terminal-bg: #010409;
--color-terminal-border: #30363d;
--color-terminal-text: #c9d1d9;
--color-terminal-muted: #8b949e;
--color-terminal-cyan: #58a6ff;
--color-terminal-amber: #d29922;
--color-terminal-red: #ff7b72;
--color-terminal-blue: #79c0ff;
--color-terminal-purple: #d2a8ff;
}
.bg-background-dark { background-color: var(--color-background-dark); }
.bg-terminal-bg { background-color: var(--color-terminal-bg); }
.bg-terminal-bg\/50 { background-color: rgba(1, 4, 9, 0.5); }
.border-terminal-border { border-color: var(--color-terminal-border); }
.border-terminal-border\/50 { border-color: rgba(48, 54, 61, 0.5); }
.border-terminal-border\/20 { border-color: rgba(48, 54, 61, 0.2); }
.text-terminal-text { color: var(--color-terminal-text); }
.text-terminal-muted { color: var(--color-terminal-muted); }
.text-terminal-cyan { color: var(--color-terminal-cyan); }
.text-terminal-amber { color: var(--color-terminal-amber); }
.text-terminal-red { color: var(--color-terminal-red); }
.text-terminal-blue { color: var(--color-terminal-blue); }
.text-terminal-purple { color: var(--color-terminal-purple); }
.text-primary { color: var(--color-primary); }
.bg-primary { background-color: var(--color-primary); }
.border-primary { border-color: var(--color-primary); }
.border-primary\/50 { border-color: rgba(63, 185, 80, 0.5); }
.hover\:text-primary:hover { color: var(--color-primary); }
.hover\:border-primary\/50:hover { border-color: rgba(63, 185, 80, 0.5); }
.selection\:bg-terminal-cyan::selection { background-color: var(--color-terminal-cyan); }
.selection\:text-background-dark::selection { color: var(--color-background-dark); }
/* Font families */
.font-display { font-family: "Space Grotesk", sans-serif; }
.font-mono { font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* Accessibility - Screen Reader Only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.focus\:not-sr-only:focus {
position: absolute;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}
</style>
<style>
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #0d1117; }
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #8b949e; }
.cursor-blink { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.syntax-key { color: #58a6ff; }
.syntax-string { color: #3fb950; }
.syntax-number { color: #79c0ff; }
.syntax-colon { color: #c9d1d9; }
.line-num { color: #484f58; user-select: none; text-align: right; padding-right: 1rem; }
.typing-effect { overflow: hidden; white-space: nowrap; animation: typing 2s steps(40, end); }
@keyframes typing { from { width: 0 } to { width: 100% } }
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
.cursor-blink { animation: none; }
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body class="bg-background-dark text-terminal-text font-mono min-h-screen flex flex-col overflow-x-hidden selection:bg-terminal-cyan selection:text-background-dark">
<!-- Skip Link for Accessibility -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-background-dark focus:rounded focus:outline-none">
메인 콘텐츠로 건너뛰기
</a>
<div class="layout-container flex h-full grow flex-col items-center justify-center p-4 md:p-8 lg:p-12">
<!-- Main Terminal Window -->
<div class="w-full max-w-[1400px] bg-background-dark border border-terminal-border rounded-lg shadow-[0_0_50px_-12px_rgba(0,0,0,0.7)] overflow-hidden flex flex-col">
<!-- Window Title Bar -->
<div class="bg-[#161b22] px-4 py-3 flex items-center justify-between border-b border-terminal-border select-none">
<div class="flex items-center gap-2" aria-hidden="true">
<div class="w-3 h-3 rounded-full bg-[#ff5f56]"></div>
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
</div>
<div class="flex items-center gap-2 opacity-70">
<span class="material-symbols-outlined text-[14px]" aria-hidden="true">terminal</span>
<span class="text-xs md:text-sm text-terminal-muted font-display">root@anvil-cloud:~</span>
</div>
<a href="https://t.me/AnvilForgeBot" target="_blank" class="text-xs text-terminal-muted hover:text-primary transition flex items-center gap-1" aria-label="텔레그램 봇 @AnvilForgeBot 열기">
<span>@AnvilForgeBot</span>
<span class="material-symbols-outlined text-sm" aria-hidden="true">open_in_new</span>
</a>
</div>
<!-- Terminal Content -->
<div class="p-6 md:p-10 flex flex-col gap-12 relative bg-gradient-to-br from-background-dark to-[#0f141a]">
<!-- Background Grid -->
<div class="absolute inset-0 opacity-[0.03]" style="background-image: linear-gradient(#30363d 1px, transparent 1px), linear-gradient(90deg, #30363d 1px, transparent 1px); background-size: 40px 40px;"></div>
<!-- Prompt -->
<div class="flex flex-wrap items-center gap-2 text-sm z-10 font-bold">
<span class="text-primary">anvil@hosting</span>
<span class="text-terminal-muted">:</span>
<span class="text-terminal-blue">~</span>
<span class="text-terminal-muted">$</span>
<span class="text-terminal-text">cat README.md</span>
</div>
<!-- Hero Section -->
<div class="flex flex-col gap-6 z-10" id="main-content">
<!-- ASCII Art Logo -->
<div class="flex items-end gap-3">
<pre aria-hidden="true" class="text-primary font-bold leading-[0.85] text-[8px] sm:text-[10px] md:text-xs select-none"> █████╗ ███╗ ██╗██╗ ██╗██╗██╗
██╔══██╗████╗ ██║██║ ██║██║██║
███████║██╔██╗ ██║██║ ██║██║██║
██╔══██║██║╚██╗██║╚██╗ ██╔╝██║██║
██║ ██║██║ ╚████║ ╚████╔╝ ██║███████╗
╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═══╝ ╚═╝╚══════╝</pre>
<span class="text-primary/70 text-xs md:text-sm font-medium mb-1">HOSTING</span>
</div>
<!-- Main Headline with Rotation -->
<div class="space-y-6 max-w-4xl mt-4" x-data="headlineRotator()">
<div class="min-h-[120px] md:min-h-[160px] lg:min-h-[200px] flex flex-col justify-center" aria-live="polite" aria-atomic="true">
<h1 class="text-2xl md:text-4xl lg:text-5xl font-display font-bold text-white tracking-tight leading-tight">
<span class="text-terminal-muted mr-2">&gt;</span><span x-text="displayedLine1"></span><span x-show="isTypingLine1" class="text-primary cursor-blink">_</span><br/>
<span class="text-terminal-muted mr-2">&gt;</span><span class="text-primary" x-text="displayedLine2"></span><span x-show="isTypingLine2 || (!isTypingLine1 && !isTypingLine2)" class="text-primary cursor-blink">_</span>
</h1>
</div>
</div>
<!-- Typing Carousel -->
<div class="w-full max-w-3xl mt-6" x-data="typingCarousel()">
<div class="bg-terminal-bg border border-terminal-border rounded-lg overflow-hidden shadow-lg">
<!-- Terminal Header -->
<div class="bg-[#161b22] px-4 py-2 border-b border-terminal-border flex items-center gap-2">
<span class="text-terminal-muted text-xs">anvil-cli v2.4.0</span>
<span class="ml-auto text-xs text-terminal-muted" x-text="(currentIndex + 1) + '/' + commands.length"></span>
</div>
<!-- Command Display -->
<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'" aria-live="polite">
<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>
<!-- Navigation Dots -->
<div class="flex justify-center gap-2 mt-4">
<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>
<!-- Features Grid -->
<div class="grid md:grid-cols-3 gap-6 z-10 border-t border-terminal-border/50 pt-12" id="features">
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-primary/50 transition-colors">
<div class="text-primary text-2xl mb-3"></div>
<h3 class="font-display font-bold text-white mb-2">즉시 프로비저닝</h3>
<p class="text-terminal-muted text-sm">0.4초 내 인스턴스 생성. 하이퍼바이저 부팅 대기 없음.</p>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-terminal-cyan/50 transition-colors">
<div class="text-terminal-cyan text-2xl mb-3">🛡️</div>
<h3 class="font-display font-bold text-white mb-2">1Tbps+ DDoS 방어</h3>
<p class="text-terminal-muted text-sm">ForgeShield 통합 보호. L3/L4/L7 완전 방어.</p>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded-lg p-6 hover:border-terminal-amber/50 transition-colors">
<div class="text-terminal-amber text-2xl mb-3">🤖</div>
<h3 class="font-display font-bold text-white mb-2">텔레그램 봇 관리</h3>
<p class="text-terminal-muted text-sm">@AnvilForgeBot으로 서버 생성, 모니터링, 도메인 등록.</p>
</div>
</div>
<!-- Pricing Section -->
<div class="z-10 border-t border-terminal-border/50 pt-12" id="pricing" x-data="pricingTerminal()">
<!-- Section Header -->
<div class="flex items-center gap-2 text-sm text-terminal-muted mb-6">
<span class="text-primary">$</span> kubectl get instances --all-regions
</div>
<!-- Region Tabs -->
<div class="flex flex-wrap gap-2 mb-6">
<template x-for="region in regions" :key="region.id">
<button
@click="selectedRegion = region.id; filterInstances()"
:class="selectedRegion === region.id ? 'bg-primary text-background-dark' : 'bg-terminal-bg text-terminal-text hover:bg-terminal-border'"
class="px-3 py-1.5 rounded text-xs font-bold transition-colors border border-terminal-border flex items-center gap-2"
>
<span x-text="region.flag"></span>
<span x-text="region.name"></span>
<span class="opacity-60" x-text="'(' + getRegionCount(region.id) + ')'"></span>
</button>
</template>
</div>
<!-- Command Line -->
<div class="bg-terminal-bg border border-terminal-border rounded-t-lg px-4 py-2 text-xs text-terminal-muted flex items-center justify-between">
<span>
<span class="text-primary">$</span> kubectl get instances --region=<span class="text-terminal-cyan" x-text="selectedRegion"></span> --sort-by=<span class="text-terminal-amber" x-text="sortBy"></span>
</span>
<span x-show="loading" class="text-terminal-amber animate-pulse">fetching...</span>
<span x-show="!loading && lastUpdate" class="text-terminal-muted" x-text="'synced ' + getLastUpdateText()"></span>
</div>
<!-- Instances Table -->
<div class="bg-terminal-bg border-x border-b border-terminal-border rounded-b-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-terminal-border bg-[#161b22] text-left">
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white" @click="toggleSort('name')">
NAME <span x-show="sortBy === 'name'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('vcpu')">
CPU <span x-show="sortBy === 'vcpu'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-center" @click="toggleSort('memory')">
RAM <span x-show="sortBy === 'memory'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted text-center hidden sm:table-cell">DISK</th>
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted text-center hidden md:table-cell">TRAFFIC</th>
<th scope="col" class="px-4 py-3 font-bold text-terminal-muted cursor-pointer hover:text-white text-right" @click="toggleSort('price')">
PRICE/MO <span x-show="sortBy === 'price'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
</tr>
</thead>
<tbody aria-live="polite">
<!-- Loading State -->
<template x-if="loading">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
<span class="animate-pulse">Fetching instances from API...</span>
</td>
</tr>
</template>
<!-- Empty State -->
<template x-if="!loading && filteredInstances.length === 0">
<tr>
<td colspan="6" class="px-4 py-8 text-center text-terminal-muted">
No instances found for this region.
</td>
</tr>
</template>
<!-- Instance Rows -->
<template x-for="(inst, index) in filteredInstances" :key="inst.id + '-' + inst.region?.display_name">
<tr class="border-b border-terminal-border/50 hover:bg-terminal-border/20 transition-colors">
<td class="px-4 py-3">
<span class="text-terminal-cyan" x-text="inst.id || inst.instance_name"></span>
</td>
<td class="px-4 py-3 text-center">
<span class="text-terminal-amber" x-text="inst.vcpu"></span>
</td>
<td class="px-4 py-3 text-center">
<span class="text-primary" x-text="formatMemory(inst.memory_mb)"></span>
</td>
<td class="px-4 py-3 text-center text-terminal-muted hidden sm:table-cell" x-text="inst.storage_gb + 'GB'"></td>
<td class="px-4 py-3 text-center text-terminal-muted hidden md:table-cell" x-text="inst.transfer_tb ? inst.transfer_tb + 'TB' : '-'"></td>
<td class="px-4 py-3 text-right">
<span class="text-primary font-bold text-lg" x-text="'₩' + (inst.pricing?.monthly_price_krw || 0).toLocaleString()"></span>
<span class="text-terminal-muted text-xs block" x-text="'$' + inst.pricing?.monthly_price?.toFixed(2)"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Table Footer -->
<div class="px-4 py-3 border-t border-terminal-border bg-[#161b22] text-xs text-terminal-muted flex justify-between items-center">
<span>
<span class="text-primary" x-text="filteredInstances.length"></span> instances
<span x-show="fromCache" class="text-terminal-amber ml-2">(cached)</span>
</span>
<button @click="forceRefresh()" class="hover:text-primary transition-colors flex items-center gap-1" :disabled="loading" aria-label="가격 정보 새로고침">
<span class="material-symbols-outlined text-sm" :class="loading && 'animate-spin'" aria-hidden="true">refresh</span>
refresh
</button>
</div>
</div>
<!-- Server Specs -->
<div class="mt-8 grid md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># Storage</div>
<div class="text-white font-bold">NVMe Gen 4</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># Network</div>
<div class="text-white font-bold">25Gbps Private</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># CPU</div>
<div class="text-white font-bold">AMD EPYC / Xeon</div>
</div>
<div class="bg-terminal-bg/50 border border-terminal-border rounded p-4">
<div class="text-terminal-muted text-xs mb-1"># IP</div>
<div class="text-white font-bold">IPv4 + IPv6</div>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="flex flex-col items-center justify-center py-12 z-10 gap-8 border-t border-terminal-border/50">
<div class="text-center space-y-2">
<h2 class="text-3xl font-display font-bold text-white">$ ./deploy --now</h2>
<p class="text-terminal-muted">텔레그램에서 바로 서버를 생성하세요</p>
</div>
<a href="https://t.me/AnvilForgeBot" target="_blank" class="group relative flex items-center justify-center">
<div class="absolute -inset-1 rounded-lg bg-gradient-to-r from-primary via-terminal-cyan to-primary opacity-75 blur transition duration-500 group-hover:opacity-100 animate-pulse"></div>
<div class="relative flex items-center bg-terminal-bg rounded-lg px-6 py-4 border border-primary">
<span class="text-primary font-bold mr-3">$</span>
<span class="text-terminal-text font-bold text-lg">curl -sSL t.me/AnvilForgeBot | start</span>
<span class="ml-4 material-symbols-outlined text-primary group-hover:translate-x-1 transition-transform">arrow_forward</span>
</div>
</a>
<div class="text-xs text-terminal-muted flex gap-6">
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/terms.html">--terms</a>
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/privacy.html">--privacy</a>
<a class="hover:text-primary underline decoration-primary/30 underline-offset-4" href="/sla.html">--sla</a>
</div>
</div>
</div>
<!-- Footer / Vim Status Bar -->
<div class="bg-primary text-background-dark text-xs font-bold py-1 px-4 flex justify-between items-center select-none">
<div class="flex gap-4">
<span class="bg-background-dark/20 px-2 py-0.5 rounded text-white/90">NORMAL</span>
<span>index.html</span>
</div>
<div class="flex gap-4">
<span class="hidden sm:inline">utf-8</span>
<span class="hidden sm:inline">unix</span>
<span>100%</span>
<span>Ln 1, Col 1</span>
</div>
</div>
</div>
<footer class="mt-8 text-terminal-muted text-xs text-center">
© 2024 Anvil Hosting. All containers running. PID 1 healthy.
</footer>
</div>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"
integrity="sha384-iZD2X8o1Zdq0HR5H/7oa8W30WS4No+zWCKUPD7fHRay9I1Gf+C4F8sVmw7zec1wW"
crossorigin="anonymous"></script>
<script>
function headlineRotator() {
return {
headlines: [
{ line1: '웹, DB, API, 게임서버', line2: '거뜬합니다' },
{ line1: '엔지니어가 튜닝한', line2: '최적의 컨디션' },
{ line1: '고객의 문제가 해결될 때까지', line2: '우리는 멈추지 않습니다' },
{ line1: '도쿄 · 오사카 · 서울 · 싱가포르', line2: '아시아 전역 커버' },
{ line1: '복잡한 서버 관리는 이제 그만', line2: '지금부터 AI가 합니다' },
],
currentIndex: 0,
progress: 0,
displayedLine1: '',
displayedLine2: '',
isTypingLine1: true,
isTypingLine2: false,
typingId: null,
progressId: null,
init() {
this.startTyping();
},
startTyping() {
this.displayedLine1 = '';
this.displayedLine2 = '';
this.isTypingLine1 = true;
this.isTypingLine2 = false;
this.progress = 0;
if (this.typingId) clearInterval(this.typingId);
if (this.progressId) clearInterval(this.progressId);
const line1 = this.headlines[this.currentIndex].line1;
const line2 = this.headlines[this.currentIndex].line2;
let charIndex = 0;
// Type line 1
this.typingId = setInterval(() => {
if (charIndex < line1.length) {
this.displayedLine1 = line1.substring(0, charIndex + 1);
charIndex++;
} else {
clearInterval(this.typingId);
this.isTypingLine1 = false;
this.isTypingLine2 = true;
charIndex = 0;
// Type line 2
this.typingId = setInterval(() => {
if (charIndex < line2.length) {
this.displayedLine2 = line2.substring(0, charIndex + 1);
charIndex++;
} else {
clearInterval(this.typingId);
this.isTypingLine2 = false;
this.startProgress();
}
}, 60);
}
}, 60);
},
startProgress() {
this.progress = 0;
const duration = 3000;
const step = 100 / (duration / 50);
this.progressId = setInterval(() => {
this.progress += step;
if (this.progress >= 100) {
clearInterval(this.progressId);
this.next();
}
}, 50);
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.headlines.length;
this.startTyping();
},
goTo(index) {
if (this.typingId) clearInterval(this.typingId);
if (this.progressId) clearInterval(this.progressId);
this.currentIndex = index;
this.startTyping();
}
};
}
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() {
return {
// State
instances: [],
filteredInstances: [],
selectedRegion: 'tokyo-1',
sortBy: 'price',
sortOrder: 'asc',
loading: false,
lastUpdate: null,
fromCache: false,
// Region definitions
regions: [
{ id: 'tokyo-1', name: 'Tokyo 1', flag: '🇯🇵', filter: ['tokyo 1'] },
{ id: 'tokyo-2', name: 'Tokyo 2', flag: '🇯🇵', filter: ['tokyo 2'] },
{ id: 'tokyo-3', name: 'Tokyo 3', flag: '🇯🇵', filter: ['tokyo 3'] },
{ id: 'osaka-1', name: 'Osaka 1', flag: '🇯🇵', filter: ['osaka 1'] },
{ id: 'osaka-2', name: 'Osaka 2', flag: '🇯🇵', filter: ['osaka 2'] },
{ id: 'seoul-1', name: 'Seoul 1', flag: '🇰🇷', filter: ['seoul'] },
{ id: 'singapore-1', name: 'Singapore 1', flag: '🇸🇬', filter: ['singapore'] },
],
// Initialize
async init() {
await this.loadInstances();
},
// Load from API
async loadInstances() {
// Check cache first
const cached = this.getCache();
if (cached) {
this.instances = cached.instances;
this.lastUpdate = new Date(cached.timestamp);
this.fromCache = true;
this.filterInstances();
return;
}
await this.fetchFromApi();
},
// Fetch from API
async fetchFromApi() {
this.loading = true;
this.fromCache = false;
try {
const res = await fetch('/api/pricing');
const data = await res.json();
if (data.success && data.instances) {
this.instances = data.instances;
this.setCache(data.instances);
this.lastUpdate = new Date();
}
} catch (e) {
console.error('[Pricing] Fetch error:', e);
// Use fallback data
this.instances = this.getFallbackData();
} finally {
this.loading = false;
this.filterInstances();
}
},
// Filter instances by region
filterInstances() {
const region = this.regions.find(r => r.id === this.selectedRegion);
if (!region) return;
this.filteredInstances = this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
// GPU 제외, 리전 필터 매칭
const matchesFilter = region.filter.some(f => displayName.includes(f));
return matchesFilter && !hasGpu;
});
// Sort
this.sortInstances();
},
// Sort instances
sortInstances() {
const order = this.sortOrder === 'asc' ? 1 : -1;
this.filteredInstances.sort((a, b) => {
let diff = 0;
switch (this.sortBy) {
case 'price':
diff = (a.pricing?.monthly_price || 0) - (b.pricing?.monthly_price || 0);
break;
case 'vcpu':
diff = (a.vcpu || 0) - (b.vcpu || 0);
break;
case 'memory':
diff = (a.memory_mb || 0) - (b.memory_mb || 0);
break;
case 'name':
diff = (a.id || '').localeCompare(b.id || '');
break;
}
return diff * order;
});
},
// Toggle sort
toggleSort(column) {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = column;
this.sortOrder = column === 'price' ? 'asc' : 'desc';
}
this.sortInstances();
},
// Get region count
getRegionCount(regionId) {
const region = this.regions.find(r => r.id === regionId);
if (!region) return 0;
return this.instances.filter(inst => {
const displayName = (inst.region?.display_name || '').toLowerCase();
const hasGpu = inst.has_gpu || inst.category === 'gpu';
const matchesFilter = region.filter.some(f => displayName.includes(f));
return matchesFilter && !hasGpu;
}).length;
},
// Format memory
formatMemory(mb) {
if (!mb) return '-';
return mb >= 1024 ? (mb / 1024) + 'GB' : mb + 'MB';
},
// Last update text
getLastUpdateText() {
if (!this.lastUpdate) return '';
const diff = Math.floor((Date.now() - this.lastUpdate) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return this.lastUpdate.toLocaleTimeString();
},
// Force refresh
async forceRefresh() {
localStorage.removeItem('anvil_pricing_cache_v6');
await this.fetchFromApi();
},
// Cache helpers
getCache() {
try {
const data = localStorage.getItem('anvil_pricing_cache_v6');
if (!data) return null;
const parsed = JSON.parse(data);
if (Date.now() - parsed.timestamp > 3600000) return null; // 1h TTL
return parsed;
} catch { return null; }
},
setCache(instances) {
try {
localStorage.setItem('anvil_pricing_cache_v6', JSON.stringify({
instances, timestamp: Date.now()
}));
} catch {}
},
// Fallback data
getFallbackData() {
return [
{ id: 'anvil-1g-1c', vcpu: 1, memory_mb: 1024, storage_gb: 25, transfer_tb: 1, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 6, monthly_price_krw: 8500 } },
{ id: 'anvil-2g-1c', vcpu: 1, memory_mb: 2048, storage_gb: 50, transfer_tb: 2, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 12, monthly_price_krw: 17000 } },
{ id: 'anvil-4g-2c', vcpu: 2, memory_mb: 4096, storage_gb: 80, transfer_tb: 4, region: { display_name: 'Tokyo 1' }, pricing: { monthly_price: 24, monthly_price_krw: 34000 } },
];
}
};
}
</script>
</body>
</html>