hm
This commit is contained in:
844
static/css/styles.css
Normal file
844
static/css/styles.css
Normal file
@@ -0,0 +1,844 @@
|
||||
/* Theme Variables */
|
||||
:root {
|
||||
/* Light theme (default fallback) */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-input: #ffffff;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--border-hover: #adb5bd;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
--accent: #0d6efd;
|
||||
--accent-hover: #0b5ed7;
|
||||
|
||||
/* Smooth theme transitions */
|
||||
transition: background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #0b0f14;
|
||||
--bg-secondary: #121821;
|
||||
--bg-input: #0d131b;
|
||||
--text-primary: #e8eef7;
|
||||
--text-secondary: #adb5bd;
|
||||
--border-color: #223042;
|
||||
--border-hover: #495057;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--accent: #6ea8fe;
|
||||
--accent-hover: #9ec5fe;
|
||||
}
|
||||
}
|
||||
|
||||
/* Manual theme overrides */
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-input: #ffffff;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--border-hover: #adb5bd;
|
||||
--shadow: rgba(0, 0, 0, 0.1);
|
||||
--accent: #0d6efd;
|
||||
--accent-hover: #0b5ed7;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0b0f14;
|
||||
--bg-secondary: #121821;
|
||||
--bg-input: #0d131b;
|
||||
--text-primary: #e8eef7;
|
||||
--text-secondary: #adb5bd;
|
||||
--border-color: #223042;
|
||||
--border-hover: #495057;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--accent: #6ea8fe;
|
||||
--accent-hover: #9ec5fe;
|
||||
}
|
||||
|
||||
/* Smooth theme transitions for all elements */
|
||||
* {
|
||||
transition: background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Enhanced smooth transition during theme changes */
|
||||
.theme-transitioning * {
|
||||
transition-duration: 0.6s !important;
|
||||
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94) !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated background elements */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background:
|
||||
radial-gradient(circle at 25% 25%, rgba(13, 110, 253, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(110, 168, 254, 0.08) 0%, transparent 50%);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
||||
33% { transform: rotate(1deg) scale(1.1); }
|
||||
66% { transform: rotate(-1deg) scale(0.9); }
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
background: linear-gradient(145deg, var(--bg-secondary), var(--bg-input));
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
box-shadow:
|
||||
0 20px 40px -12px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow:
|
||||
0 32px 64px -12px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: fixed;
|
||||
top: var(--logo-y, 20px);
|
||||
left: var(--logo-x, 20px);
|
||||
max-height: 80px;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
width: auto;
|
||||
z-index: 100;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.logo:active,
|
||||
.logo.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.logo.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
position: fixed;
|
||||
top: var(--typewriter-y, 110px);
|
||||
left: var(--typewriter-x, 20px);
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-input));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 8px 12px;
|
||||
box-shadow:
|
||||
0 8px 24px -6px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
z-index: 98;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.info-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.info-box:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 12px 32px -6px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.info-box:active,
|
||||
.info-box.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.98);
|
||||
box-shadow:
|
||||
0 6px 20px -4px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.typewriter-text {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Courier New', monospace;
|
||||
min-height: 14px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-feature-settings: "kern" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Emoji rendering improvements */
|
||||
font-variant-emoji: emoji;
|
||||
-webkit-font-feature-settings: "liga", "kern";
|
||||
font-synthesis: none;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.typewriter-text::after {
|
||||
content: '|';
|
||||
color: var(--accent);
|
||||
animation: blink 1s infinite;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.typewriter-text.typing::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.typewriter-text.paused::after {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.info-box {
|
||||
top: 85px;
|
||||
left: 15px;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.typewriter-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
left: 15px;
|
||||
max-height: 65px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.typewriter-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme-specific styling */
|
||||
[data-theme="dark"] .info-box {
|
||||
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-input) 100%);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 2px 8px var(--shadow), 0 0 20px rgba(110, 168, 254, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .info-box {
|
||||
background: linear-gradient(135deg, var(--bg-secondary) 0%, #ffffff 100%);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: var(--theme-button-y, 20px);
|
||||
right: 20px;
|
||||
background: var(--bg-input);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Custom positioned theme button */
|
||||
.theme-toggle.positioned {
|
||||
top: var(--theme-button-y);
|
||||
left: var(--theme-button-x);
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-secondary);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px var(--shadow);
|
||||
}
|
||||
|
||||
.theme-toggle:active,
|
||||
.theme-toggle.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 25px var(--shadow);
|
||||
}
|
||||
|
||||
.theme-toggle.bounce {
|
||||
animation: bounce 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0) scale(1.1);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px) scale(1.15);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px) scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 var(--accent);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(13, 110, 253, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(13, 110, 253, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle.pulse {
|
||||
animation: pulse 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%, 7% { transform: rotateZ(0); }
|
||||
15% { transform: rotateZ(-15deg); }
|
||||
20% { transform: rotateZ(10deg); }
|
||||
25% { transform: rotateZ(-10deg); }
|
||||
30% { transform: rotateZ(6deg); }
|
||||
35% { transform: rotateZ(-4deg); }
|
||||
40%, 100% { transform: rotateZ(0); }
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-secondary);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px var(--shadow);
|
||||
animation: wiggle 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* Add glow effect for dark theme */
|
||||
[data-theme="dark"] .theme-toggle {
|
||||
box-shadow: 0 4px 12px var(--shadow), 0 0 20px rgba(110, 168, 254, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-toggle:hover {
|
||||
box-shadow: 0 6px 20px var(--shadow), 0 0 30px rgba(110, 168, 254, 0.2);
|
||||
}
|
||||
|
||||
/* Add subtle gradient backgrounds */
|
||||
[data-theme="light"] .theme-toggle {
|
||||
background: linear-gradient(135deg, var(--bg-input) 0%, var(--bg-secondary) 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .theme-toggle {
|
||||
background: linear-gradient(135deg, var(--bg-input) 0%, var(--bg-secondary) 100%);
|
||||
}
|
||||
|
||||
/* Smooth transitions for all states */
|
||||
.theme-toggle * {
|
||||
transition: inherit;
|
||||
}
|
||||
|
||||
/* Mobile-friendly touch improvements */
|
||||
@media (max-width: 768px) {
|
||||
.theme-toggle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--text-primary), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 20px 0 8px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(13, 110, 253, 0.1),
|
||||
0 8px 24px -8px var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
input:hover, select:hover {
|
||||
border-color: var(--border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#card-element {
|
||||
padding: 10px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
button[type="submit"]:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button[type="submit"]:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#result {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#result:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Express Checkout Styling */
|
||||
#express-checkout-element {
|
||||
margin-bottom: 28px;
|
||||
min-height: 50px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.payment-divider {
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.payment-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
|
||||
}
|
||||
|
||||
.payment-divider span {
|
||||
background: linear-gradient(145deg, var(--bg-secondary), var(--bg-input));
|
||||
padding: 8px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Express payment button styling */
|
||||
.StripeElement--express {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Card element improvements */
|
||||
#card-element {
|
||||
padding: 18px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-bottom: 24px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
#card-element:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(13, 110, 253, 0.1),
|
||||
0 8px 24px -8px var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
#card-element:hover {
|
||||
border-color: var(--border-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Improved submit button */
|
||||
#card-submit-button {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
margin-top: 16px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#card-submit-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
#card-submit-button:hover {
|
||||
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px -8px rgba(13, 110, 253, 0.4);
|
||||
}
|
||||
|
||||
#card-submit-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
#card-submit-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#card-submit-button:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Success result styling */
|
||||
#result.success {
|
||||
background: linear-gradient(135deg, #28a745, #20c997);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
#result.error {
|
||||
background: linear-gradient(135deg, #dc3545, #fd7e14);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo bounce animation */
|
||||
@keyframes logoBounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0) scale(1.05);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-4px) scale(1.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* Supporter Wall */
|
||||
.supporter-wall {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
max-height: 400px;
|
||||
background: linear-gradient(145deg, var(--bg-secondary), var(--bg-input));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow:
|
||||
0 12px 32px -8px var(--shadow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
z-index: 95;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.supporter-wall h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supporters-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.supporters-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.supporters-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.supporters-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.supporter-item {
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
animation: slideInRight 0.5s ease-out;
|
||||
}
|
||||
|
||||
.supporter-item:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.supporter-item .name {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.supporter-item .time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
float: right;
|
||||
}
|
||||
|
||||
.supporter-item.new {
|
||||
animation: newSupporter 1s ease-out;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes newSupporter {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 var(--accent);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 10px rgba(13, 110, 253, 0.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(13, 110, 253, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsiveness for supporter wall */
|
||||
@media (max-width: 768px) {
|
||||
.supporter-wall {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.supporters-list {
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsiveness for payment methods */
|
||||
@media (max-width: 768px) {
|
||||
#express-checkout-element {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.payment-divider {
|
||||
margin: 20px 0;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.payment-divider span {
|
||||
font-size: 13px;
|
||||
padding: 8px 20px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: linear-gradient(145deg, var(--bg-secondary), var(--bg-input));
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#card-element {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #6aa6ff;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
#result { margin-top: 12px; min-height: 20px; }
|
||||
BIN
static/ct.png
Normal file
BIN
static/ct.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
199
static/js/donate.js
Normal file
199
static/js/donate.js
Normal file
@@ -0,0 +1,199 @@
|
||||
(async () => {
|
||||
const cfg = await fetch("/config").then(r => r.json());
|
||||
const stripe = Stripe(cfg.publishableKey);
|
||||
|
||||
const form = document.getElementById("donation-form");
|
||||
const result = document.getElementById("result");
|
||||
const amountInput = document.getElementById("amount");
|
||||
const currencyInput = document.getElementById("currency");
|
||||
|
||||
let elements, expressCheckoutElement, cardElement, paymentIntent;
|
||||
|
||||
// Initialize payment elements
|
||||
async function initializePayment() {
|
||||
const amount = parseFloat(amountInput.value) || 10;
|
||||
const currency = currencyInput.value;
|
||||
const supporterName = document.getElementById('supporter-name').value;
|
||||
|
||||
// Create payment intent
|
||||
const res = await fetch("/create-payment-intent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount, currency, supporterName })
|
||||
});
|
||||
const { clientSecret } = await res.json();
|
||||
|
||||
// Create elements with appearance customization
|
||||
elements = stripe.elements({
|
||||
clientSecret,
|
||||
appearance: {
|
||||
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'night' : 'stripe'
|
||||
}
|
||||
});
|
||||
|
||||
// Create and mount express checkout element (Google Pay, Apple Pay, etc.)
|
||||
expressCheckoutElement = elements.create("expressCheckout", {
|
||||
buttonType: {
|
||||
googlePay: "donate",
|
||||
applePay: "donate"
|
||||
},
|
||||
paymentMethods: {
|
||||
link: "never" // Disable Link payment method
|
||||
}
|
||||
});
|
||||
expressCheckoutElement.mount("#express-checkout-element");
|
||||
|
||||
// Listen for express checkout readiness
|
||||
expressCheckoutElement.on('ready', (event) => {
|
||||
const paymentDivider = document.querySelector('.payment-divider');
|
||||
if (event.availablePaymentMethods && Object.keys(event.availablePaymentMethods).length > 0) {
|
||||
// Express methods are available, show the divider
|
||||
paymentDivider.style.display = 'block';
|
||||
} else {
|
||||
// No express methods available, hide the divider on desktop but keep on mobile
|
||||
if (window.innerWidth <= 768) {
|
||||
paymentDivider.style.display = 'block';
|
||||
paymentDivider.querySelector('span').textContent = 'Pay with card';
|
||||
} else {
|
||||
paymentDivider.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create and mount card element
|
||||
cardElement = elements.create("card", {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--text-primary'),
|
||||
'::placeholder': {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--text-secondary'),
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
cardElement.mount("#card-element");
|
||||
|
||||
return clientSecret;
|
||||
}
|
||||
|
||||
// Handle express checkout (Google Pay, Apple Pay, etc.)
|
||||
async function handleExpressPayment() {
|
||||
result.textContent = "Processing...";
|
||||
|
||||
try {
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: "if_required"
|
||||
});
|
||||
|
||||
if (error) {
|
||||
result.textContent = "❌ " + error.message;
|
||||
} else {
|
||||
result.textContent = "✅ Thank you! Support received successfully.";
|
||||
showSuccessAnimation();
|
||||
addToSupporterWall();
|
||||
}
|
||||
} catch (err) {
|
||||
result.textContent = "❌ Payment failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle card payment
|
||||
async function handleCardPayment(e) {
|
||||
e.preventDefault();
|
||||
result.textContent = "Processing...";
|
||||
|
||||
try {
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.href,
|
||||
},
|
||||
redirect: "if_required"
|
||||
});
|
||||
|
||||
if (error) {
|
||||
result.textContent = "❌ " + error.message;
|
||||
} else {
|
||||
result.textContent = "✅ Thank you! Support received successfully.";
|
||||
showSuccessAnimation();
|
||||
addToSupporterWall();
|
||||
}
|
||||
} catch (err) {
|
||||
result.textContent = "❌ Payment failed. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// Success animation
|
||||
function showSuccessAnimation() {
|
||||
result.style.background = 'linear-gradient(135deg, #28a745, #20c997)';
|
||||
result.style.color = 'white';
|
||||
result.style.padding = '12px';
|
||||
result.style.borderRadius = '8px';
|
||||
result.style.animation = 'bounce 0.6s ease-out';
|
||||
}
|
||||
|
||||
// Add to supporter wall
|
||||
async function addToSupporterWall() {
|
||||
const supporterName = document.getElementById('supporter-name').value.trim();
|
||||
const amount = parseFloat(amountInput.value) || 0;
|
||||
const currency = currencyInput.value;
|
||||
|
||||
if (supporterName && window.supporterWall) {
|
||||
await window.supporterWall.addSupporter(supporterName, amount * 100, currency);
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize when amount or currency changes
|
||||
async function reinitialize() {
|
||||
if (elements) {
|
||||
elements.destroy();
|
||||
}
|
||||
|
||||
const amount = parseFloat(amountInput.value);
|
||||
if (amount && amount > 0) {
|
||||
await initializePayment();
|
||||
|
||||
// Set up event listeners
|
||||
if (expressCheckoutElement) {
|
||||
expressCheckoutElement.on('click', handleExpressPayment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
form.addEventListener("submit", handleCardPayment);
|
||||
amountInput.addEventListener("blur", reinitialize);
|
||||
currencyInput.addEventListener("change", reinitialize);
|
||||
|
||||
// Initialize on page load
|
||||
await initializePayment();
|
||||
|
||||
// Set up express checkout event listener
|
||||
if (expressCheckoutElement) {
|
||||
expressCheckoutElement.on('click', handleExpressPayment);
|
||||
}
|
||||
|
||||
// Handle responsive payment divider visibility
|
||||
window.addEventListener('resize', () => {
|
||||
const paymentDivider = document.querySelector('.payment-divider');
|
||||
if (window.innerWidth <= 768) {
|
||||
// Always show on mobile
|
||||
paymentDivider.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements theme when theme changes
|
||||
if (window.themeManager) {
|
||||
const originalSetTheme = window.themeManager.setTheme;
|
||||
window.themeManager.setTheme = function(theme) {
|
||||
originalSetTheme.call(this, theme);
|
||||
// Reinitialize elements with new theme
|
||||
setTimeout(reinitialize, 100);
|
||||
};
|
||||
}
|
||||
})();
|
||||
188
static/js/logo.js
Normal file
188
static/js/logo.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// Draggable Logo with Click-to-Navigate Functionality
|
||||
class DraggableLogo {
|
||||
constructor() {
|
||||
this.logo = document.querySelector('.logo');
|
||||
this.isDragging = false;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.position = { x: 0, y: 0 };
|
||||
this.clickStartTime = 0;
|
||||
this.startPosition = { x: 0, y: 0 };
|
||||
this.clickThreshold = 200; // ms
|
||||
this.website = 'https://computertech.dev';
|
||||
|
||||
if (this.logo) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadPosition();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Mouse events
|
||||
this.logo.addEventListener('mousedown', this.handleStart.bind(this));
|
||||
document.addEventListener('mousemove', this.handleMove.bind(this));
|
||||
document.addEventListener('mouseup', this.handleEnd.bind(this));
|
||||
|
||||
// Touch events for mobile
|
||||
this.logo.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
|
||||
document.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
|
||||
this.logo.addEventListener('touchend', this.handleEnd.bind(this));
|
||||
|
||||
// Prevent context menu
|
||||
this.logo.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Double-click to snap to corner
|
||||
this.logo.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
this.snapToNearestCorner();
|
||||
});
|
||||
|
||||
// Click handler (fallback)
|
||||
this.logo.addEventListener('click', (e) => {
|
||||
if (!this.isDragging) {
|
||||
e.preventDefault();
|
||||
window.open(this.website, '_blank');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleStart(e) {
|
||||
e.preventDefault();
|
||||
this.clickStartTime = Date.now();
|
||||
this.isDragging = false;
|
||||
this.startPosition = { x: 0, y: 0 };
|
||||
|
||||
const rect = this.logo.getBoundingClientRect();
|
||||
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
this.dragOffset.x = clientX - rect.left;
|
||||
this.dragOffset.y = clientY - rect.top;
|
||||
this.startPosition.x = clientX;
|
||||
this.startPosition.y = clientY;
|
||||
|
||||
this.logo.classList.add('dragging');
|
||||
this.logo.classList.remove('clickable');
|
||||
}
|
||||
|
||||
handleMove(e) {
|
||||
if (!this.logo.classList.contains('dragging')) return;
|
||||
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
const deltaX = Math.abs(clientX - this.startPosition.x);
|
||||
const deltaY = Math.abs(clientY - this.startPosition.y);
|
||||
const dragThreshold = 5;
|
||||
|
||||
if (deltaX > dragThreshold || deltaY > dragThreshold) {
|
||||
this.isDragging = true;
|
||||
e.preventDefault();
|
||||
|
||||
this.position.x = clientX - this.dragOffset.x;
|
||||
this.position.y = clientY - this.dragOffset.y;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const logoRect = this.logo.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
|
||||
this.position.x = Math.max(margin, Math.min(window.innerWidth - logoRect.width - margin, this.position.x));
|
||||
this.position.y = Math.max(margin, Math.min(window.innerHeight - logoRect.height - margin, this.position.y));
|
||||
|
||||
this.logo.style.left = this.position.x + 'px';
|
||||
this.logo.style.top = this.position.y + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
handleEnd(e) {
|
||||
this.logo.classList.remove('dragging');
|
||||
|
||||
const clickDuration = Date.now() - this.clickStartTime;
|
||||
|
||||
if (!this.isDragging && clickDuration < this.clickThreshold) {
|
||||
// It was a click, navigate to website
|
||||
this.logo.classList.add('clickable');
|
||||
setTimeout(() => {
|
||||
window.open(this.website, '_blank');
|
||||
}, 100);
|
||||
} else if (this.isDragging) {
|
||||
// It was a drag, save position
|
||||
this.savePosition();
|
||||
this.addBounceEffect();
|
||||
}
|
||||
|
||||
// Reset dragging state with small delay
|
||||
setTimeout(() => {
|
||||
this.isDragging = false;
|
||||
this.logo.classList.remove('clickable');
|
||||
}, 50);
|
||||
}
|
||||
|
||||
savePosition() {
|
||||
localStorage.setItem('logoPosition', JSON.stringify(this.position));
|
||||
// Update CSS variables for immediate positioning on reload
|
||||
document.documentElement.style.setProperty('--logo-x', this.position.x + 'px');
|
||||
document.documentElement.style.setProperty('--logo-y', this.position.y + 'px');
|
||||
}
|
||||
|
||||
loadPosition() {
|
||||
const savedPosition = localStorage.getItem('logoPosition');
|
||||
if (savedPosition) {
|
||||
this.position = JSON.parse(savedPosition);
|
||||
this.logo.style.left = this.position.x + 'px';
|
||||
this.logo.style.top = this.position.y + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
snapToNearestCorner() {
|
||||
const logoRect = this.logo.getBoundingClientRect();
|
||||
const margin = 20;
|
||||
|
||||
const corners = [
|
||||
{ x: margin, y: margin },
|
||||
{ x: window.innerWidth - logoRect.width - margin, y: margin },
|
||||
{ x: margin, y: window.innerHeight - logoRect.height - margin },
|
||||
{ x: window.innerWidth - logoRect.width - margin, y: window.innerHeight - logoRect.height - margin }
|
||||
];
|
||||
|
||||
let nearestCorner = corners[0];
|
||||
let minDistance = Infinity;
|
||||
|
||||
corners.forEach(corner => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(corner.x - this.position.x, 2) +
|
||||
Math.pow(corner.y - this.position.y, 2)
|
||||
);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearestCorner = corner;
|
||||
}
|
||||
});
|
||||
|
||||
this.position = nearestCorner;
|
||||
this.logo.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
this.logo.style.left = nearestCorner.x + 'px';
|
||||
this.logo.style.top = nearestCorner.y + 'px';
|
||||
|
||||
setTimeout(() => {
|
||||
this.logo.style.transition = '';
|
||||
this.savePosition();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
addBounceEffect() {
|
||||
this.logo.style.animation = 'bounce 0.6s ease-out';
|
||||
setTimeout(() => {
|
||||
this.logo.style.animation = '';
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize draggable logo when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.draggableLogo = new DraggableLogo();
|
||||
});
|
||||
142
static/js/supporters.js
Normal file
142
static/js/supporters.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// Supporter Wall Functionality
|
||||
class SupporterWall {
|
||||
constructor() {
|
||||
this.supportersList = document.getElementById('supporters-list');
|
||||
this.supporterWall = document.getElementById('supporter-wall');
|
||||
this.supporters = [];
|
||||
|
||||
if (this.supportersList) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadSupporters();
|
||||
// Refresh supporters every 30 seconds
|
||||
setInterval(() => this.loadSupporters(), 30000);
|
||||
}
|
||||
|
||||
async loadSupporters() {
|
||||
try {
|
||||
const response = await fetch('/supporters');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.supporters && data.supporters.length > 0) {
|
||||
this.updateSupportersList(data.supporters);
|
||||
} else {
|
||||
this.showEmptyState();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading supporters:', error);
|
||||
this.showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
updateSupportersList(newSupporters) {
|
||||
// Clear loading message
|
||||
this.supportersList.innerHTML = '';
|
||||
|
||||
// Check for new supporters since last update
|
||||
const newSupporterIds = newSupporters.map(s => s.timestamp);
|
||||
const currentSupporterIds = this.supporters.map(s => s.timestamp);
|
||||
const hasNewSupporters = newSupporterIds.some(id => !currentSupporterIds.includes(id));
|
||||
|
||||
this.supporters = newSupporters;
|
||||
|
||||
// Create supporter elements
|
||||
newSupporters.forEach((supporter, index) => {
|
||||
const supporterElement = this.createSupporterElement(supporter);
|
||||
|
||||
// Add 'new' class to recent supporters
|
||||
if (hasNewSupporters && index === 0 && supporter.time_ago === 'just now') {
|
||||
supporterElement.classList.add('new');
|
||||
setTimeout(() => supporterElement.classList.remove('new'), 2000);
|
||||
}
|
||||
|
||||
this.supportersList.appendChild(supporterElement);
|
||||
});
|
||||
}
|
||||
|
||||
createSupporterElement(supporter) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'supporter-item';
|
||||
|
||||
const currencySymbols = {
|
||||
'GBP': '£',
|
||||
'EUR': '€',
|
||||
'USD': '$'
|
||||
};
|
||||
|
||||
const symbol = currencySymbols[supporter.currency] || supporter.currency;
|
||||
const amount = (supporter.amount / 100).toFixed(2);
|
||||
|
||||
element.innerHTML = `
|
||||
<span class="name">${this.escapeHtml(supporter.name)}</span>
|
||||
<span class="time">${supporter.time_ago}</span>
|
||||
<div style="clear: both; margin-top: 2px; font-size: 11px; color: var(--text-secondary);">
|
||||
${symbol}${amount}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
showEmptyState() {
|
||||
this.supportersList.innerHTML = `
|
||||
<div class="supporter-item" style="text-align: center; color: var(--text-secondary);">
|
||||
Be the first to support! 💜
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async addSupporter(name, amount, currency) {
|
||||
if (!name || name.trim() === '') return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/add-supporter', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
amount: amount,
|
||||
currency: currency
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Reload supporters to show the new one
|
||||
setTimeout(() => this.loadSupporters(), 1000);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding supporter:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Method to hide/show supporter wall
|
||||
toggle() {
|
||||
if (this.supporterWall) {
|
||||
this.supporterWall.style.display =
|
||||
this.supporterWall.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize supporter wall when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.supporterWall = new SupporterWall();
|
||||
});
|
||||
266
static/js/theme.js
Normal file
266
static/js/theme.js
Normal file
@@ -0,0 +1,266 @@
|
||||
// Theme Management with Draggable Fun Button
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.isDragging = false;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.position = { x: 0, y: 0 };
|
||||
this.clickStartTime = 0;
|
||||
this.clickThreshold = 200; // ms
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load saved theme or detect OS preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Set initial theme
|
||||
if (savedTheme) {
|
||||
this.setTheme(savedTheme);
|
||||
} else if (prefersDark) {
|
||||
this.setTheme('dark');
|
||||
} else {
|
||||
this.setTheme('light');
|
||||
}
|
||||
|
||||
// Listen for OS theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
this.setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
// Set up toggle button with drag functionality
|
||||
this.setupToggleButton();
|
||||
this.loadPosition();
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
// Add smooth transition class temporarily
|
||||
document.body.classList.add('theme-transitioning');
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
this.updateToggleButton(theme);
|
||||
this.addPulseEffect();
|
||||
|
||||
// Remove transition class after animation completes
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove('theme-transitioning');
|
||||
}, 400);
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
this.setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
this.addBounceEffect();
|
||||
}
|
||||
|
||||
setupToggleButton() {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
if (!toggleButton) return;
|
||||
|
||||
// Simple click handler as fallback
|
||||
toggleButton.addEventListener('click', (e) => {
|
||||
// Only handle click if we haven't detected dragging
|
||||
if (!this.isDragging) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// Mouse events for drag functionality
|
||||
toggleButton.addEventListener('mousedown', this.handleStart.bind(this));
|
||||
document.addEventListener('mousemove', this.handleMove.bind(this));
|
||||
document.addEventListener('mouseup', this.handleEnd.bind(this));
|
||||
|
||||
// Touch events for mobile
|
||||
toggleButton.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
|
||||
document.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
|
||||
document.addEventListener('touchend', this.handleEnd.bind(this));
|
||||
|
||||
// Prevent context menu on long press
|
||||
toggleButton.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// Double-click for fun bounce effect
|
||||
toggleButton.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
this.addBounceEffect();
|
||||
this.snapToNearestCorner();
|
||||
});
|
||||
}
|
||||
|
||||
handleStart(e) {
|
||||
e.preventDefault();
|
||||
this.clickStartTime = Date.now();
|
||||
this.isDragging = false;
|
||||
this.startPosition = { x: 0, y: 0 };
|
||||
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
const rect = toggleButton.getBoundingClientRect();
|
||||
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
this.dragOffset.x = clientX - rect.left;
|
||||
this.dragOffset.y = clientY - rect.top;
|
||||
this.startPosition.x = clientX;
|
||||
this.startPosition.y = clientY;
|
||||
|
||||
toggleButton.classList.add('dragging');
|
||||
}
|
||||
|
||||
handleMove(e) {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
if (!toggleButton.classList.contains('dragging')) return;
|
||||
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
// Calculate distance moved from start position
|
||||
const deltaX = Math.abs(clientX - this.startPosition.x);
|
||||
const deltaY = Math.abs(clientY - this.startPosition.y);
|
||||
const dragThreshold = 5; // pixels
|
||||
|
||||
// Only start dragging if moved more than threshold
|
||||
if (deltaX > dragThreshold || deltaY > dragThreshold) {
|
||||
this.isDragging = true;
|
||||
e.preventDefault();
|
||||
|
||||
this.position.x = clientX - this.dragOffset.x;
|
||||
this.position.y = clientY - this.dragOffset.y;
|
||||
|
||||
// Keep button within viewport bounds
|
||||
const buttonSize = 60;
|
||||
const margin = 10;
|
||||
|
||||
this.position.x = Math.max(margin, Math.min(window.innerWidth - buttonSize - margin, this.position.x));
|
||||
this.position.y = Math.max(margin, Math.min(window.innerHeight - buttonSize - margin, this.position.y));
|
||||
|
||||
toggleButton.style.left = this.position.x + 'px';
|
||||
toggleButton.style.top = this.position.y + 'px';
|
||||
toggleButton.style.right = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
handleEnd(e) {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
toggleButton.classList.remove('dragging');
|
||||
|
||||
if (this.isDragging) {
|
||||
// It was a drag, save position and add bounce effect
|
||||
this.savePosition();
|
||||
this.addBounceEffect();
|
||||
// Prevent click event from firing after drag
|
||||
setTimeout(() => {
|
||||
this.isDragging = false;
|
||||
}, 50);
|
||||
} else {
|
||||
this.isDragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
savePosition() {
|
||||
localStorage.setItem('themeButtonPosition', JSON.stringify(this.position));
|
||||
// Update CSS variables for immediate positioning on reload
|
||||
document.documentElement.style.setProperty('--theme-button-x', this.position.x + 'px');
|
||||
document.documentElement.style.setProperty('--theme-button-y', this.position.y + 'px');
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
if (toggleButton) {
|
||||
toggleButton.classList.add('positioned');
|
||||
}
|
||||
}
|
||||
|
||||
loadPosition() {
|
||||
const savedPosition = localStorage.getItem('themeButtonPosition');
|
||||
if (savedPosition) {
|
||||
this.position = JSON.parse(savedPosition);
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
if (toggleButton) {
|
||||
// Position is already set by CSS variables, just ensure the class is added
|
||||
toggleButton.classList.add('positioned');
|
||||
toggleButton.style.left = this.position.x + 'px';
|
||||
toggleButton.style.top = this.position.y + 'px';
|
||||
toggleButton.style.right = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapToNearestCorner() {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
const buttonSize = 60;
|
||||
const margin = 20;
|
||||
|
||||
const corners = [
|
||||
{ x: margin, y: margin }, // top-left
|
||||
{ x: window.innerWidth - buttonSize - margin, y: margin }, // top-right
|
||||
{ x: margin, y: window.innerHeight - buttonSize - margin }, // bottom-left
|
||||
{ x: window.innerWidth - buttonSize - margin, y: window.innerHeight - buttonSize - margin } // bottom-right
|
||||
];
|
||||
|
||||
// Find nearest corner
|
||||
let nearestCorner = corners[0];
|
||||
let minDistance = Infinity;
|
||||
|
||||
corners.forEach(corner => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(corner.x - this.position.x, 2) +
|
||||
Math.pow(corner.y - this.position.y, 2)
|
||||
);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearestCorner = corner;
|
||||
}
|
||||
});
|
||||
|
||||
// Animate to nearest corner
|
||||
this.position = nearestCorner;
|
||||
toggleButton.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
toggleButton.style.left = nearestCorner.x + 'px';
|
||||
toggleButton.style.top = nearestCorner.y + 'px';
|
||||
|
||||
setTimeout(() => {
|
||||
toggleButton.style.transition = '';
|
||||
this.savePosition();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
addBounceEffect() {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
toggleButton.classList.remove('bounce');
|
||||
// Trigger reflow
|
||||
toggleButton.offsetHeight;
|
||||
toggleButton.classList.add('bounce');
|
||||
setTimeout(() => toggleButton.classList.remove('bounce'), 600);
|
||||
}
|
||||
|
||||
addPulseEffect() {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
toggleButton.classList.remove('pulse');
|
||||
// Trigger reflow
|
||||
toggleButton.offsetHeight;
|
||||
toggleButton.classList.add('pulse');
|
||||
setTimeout(() => toggleButton.classList.remove('pulse'), 1000);
|
||||
}
|
||||
|
||||
updateToggleButton(theme) {
|
||||
const toggleButton = document.getElementById('theme-toggle');
|
||||
if (toggleButton) {
|
||||
toggleButton.innerHTML = theme === 'dark' ? '☀️' : '🌙';
|
||||
toggleButton.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
|
||||
toggleButton.setAttribute('title', `Click to switch to ${theme === 'dark' ? 'light' : 'dark'} mode • Drag to move • Double-click to snap to corner`);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme manager when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.themeManager = new ThemeManager();
|
||||
});
|
||||
295
static/js/typewriter.js
Normal file
295
static/js/typewriter.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// Typewriter Effect for Info Box with Draggable Functionality
|
||||
class TypewriterEffect {
|
||||
constructor(elementId, messages) {
|
||||
this.element = document.getElementById(elementId);
|
||||
this.infoBox = this.element ? this.element.closest('.info-box') : null;
|
||||
this.messages = messages;
|
||||
this.currentMessageIndex = 0;
|
||||
this.currentCharIndex = 0;
|
||||
this.isTyping = false;
|
||||
this.isDeleting = false;
|
||||
this.typingSpeed = 80; // ms per character
|
||||
this.deletingSpeed = 40; // ms per character when deleting
|
||||
this.pauseTime = 1500; // ms to pause at end of message
|
||||
this.deleteDelay = 800; // ms to wait before starting to delete
|
||||
|
||||
// Draggable properties
|
||||
this.isDragging = false;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.position = { x: 0, y: 0 };
|
||||
this.clickStartTime = 0;
|
||||
this.startPosition = { x: 0, y: 0 };
|
||||
|
||||
if (this.element) {
|
||||
this.init();
|
||||
this.setupDraggable();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Clear initial content and start typing
|
||||
this.element.textContent = '';
|
||||
this.loadPosition();
|
||||
setTimeout(() => this.type(), 500); // Small delay before starting
|
||||
}
|
||||
|
||||
setupDraggable() {
|
||||
if (!this.infoBox) return;
|
||||
|
||||
// Mouse events
|
||||
this.infoBox.addEventListener('mousedown', this.handleStart.bind(this));
|
||||
document.addEventListener('mousemove', this.handleMove.bind(this));
|
||||
document.addEventListener('mouseup', this.handleEnd.bind(this));
|
||||
|
||||
// Touch events for mobile
|
||||
this.infoBox.addEventListener('touchstart', this.handleStart.bind(this), { passive: false });
|
||||
document.addEventListener('touchmove', this.handleMove.bind(this), { passive: false });
|
||||
document.addEventListener('touchend', this.handleEnd.bind(this));
|
||||
|
||||
// Prevent default drag behavior on images and links
|
||||
this.infoBox.addEventListener('dragstart', (e) => e.preventDefault());
|
||||
|
||||
// Add visual feedback
|
||||
this.infoBox.style.cursor = 'grab';
|
||||
this.infoBox.style.userSelect = 'none';
|
||||
this.infoBox.style.webkitUserSelect = 'none';
|
||||
this.infoBox.style.mozUserSelect = 'none';
|
||||
this.infoBox.style.msUserSelect = 'none';
|
||||
}
|
||||
|
||||
handleStart(e) {
|
||||
this.isDragging = true;
|
||||
this.clickStartTime = Date.now();
|
||||
|
||||
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
this.startPosition = { x: clientX, y: clientY };
|
||||
|
||||
const rect = this.infoBox.getBoundingClientRect();
|
||||
this.dragOffset = {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
};
|
||||
|
||||
this.infoBox.style.cursor = 'grabbing';
|
||||
this.infoBox.classList.add('dragging');
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleMove(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
this.position = {
|
||||
x: clientX - this.dragOffset.x,
|
||||
y: clientY - this.dragOffset.y
|
||||
};
|
||||
|
||||
// Constrain to viewport
|
||||
const maxX = window.innerWidth - this.infoBox.offsetWidth;
|
||||
const maxY = window.innerHeight - this.infoBox.offsetHeight;
|
||||
|
||||
this.position.x = Math.max(0, Math.min(this.position.x, maxX));
|
||||
this.position.y = Math.max(0, Math.min(this.position.y, maxY));
|
||||
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
handleEnd(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
this.isDragging = false;
|
||||
this.infoBox.style.cursor = 'grab';
|
||||
this.infoBox.classList.remove('dragging');
|
||||
|
||||
// Save position
|
||||
this.savePosition();
|
||||
|
||||
// Check if it was a click vs drag
|
||||
const clickDuration = Date.now() - this.clickStartTime;
|
||||
const clientX = e.type === 'touchend' ? e.changedTouches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchend' ? e.changedTouches[0].clientY : e.clientY;
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(clientX - this.startPosition.x, 2) +
|
||||
Math.pow(clientY - this.startPosition.y, 2)
|
||||
);
|
||||
|
||||
// If it was a quick click with minimal movement, trigger click behavior
|
||||
if (clickDuration < 300 && distance < 10) {
|
||||
this.handleClick();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
// Skip to next message on click
|
||||
this.nextMessage();
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
if (this.infoBox) {
|
||||
this.infoBox.style.left = `${this.position.x}px`;
|
||||
this.infoBox.style.top = `${this.position.y}px`;
|
||||
}
|
||||
}
|
||||
|
||||
savePosition() {
|
||||
localStorage.setItem('typewriterPosition', JSON.stringify(this.position));
|
||||
// Update CSS variables for immediate positioning on reload
|
||||
document.documentElement.style.setProperty('--typewriter-x', this.position.x + 'px');
|
||||
document.documentElement.style.setProperty('--typewriter-y', this.position.y + 'px');
|
||||
}
|
||||
|
||||
loadPosition() {
|
||||
const saved = localStorage.getItem('typewriterPosition');
|
||||
if (saved && this.infoBox) {
|
||||
try {
|
||||
this.position = JSON.parse(saved);
|
||||
|
||||
// Ensure position is still valid for current viewport
|
||||
const maxX = window.innerWidth - this.infoBox.offsetWidth;
|
||||
const maxY = window.innerHeight - this.infoBox.offsetHeight;
|
||||
|
||||
this.position.x = Math.max(0, Math.min(this.position.x, maxX));
|
||||
this.position.y = Math.max(0, Math.min(this.position.y, maxY));
|
||||
|
||||
this.updatePosition();
|
||||
} catch (e) {
|
||||
// If parsing fails, use default position
|
||||
this.position = { x: 20, y: 110 };
|
||||
}
|
||||
} else {
|
||||
this.position = { x: 20, y: 110 };
|
||||
}
|
||||
}
|
||||
|
||||
type() {
|
||||
if (this.isDeleting) {
|
||||
this.deleteText();
|
||||
} else {
|
||||
this.addText();
|
||||
}
|
||||
}
|
||||
|
||||
addText() {
|
||||
if (this.currentCharIndex < this.messages[this.currentMessageIndex].length) {
|
||||
const currentMessage = this.messages[this.currentMessageIndex];
|
||||
this.element.textContent = currentMessage.substring(0, this.currentCharIndex + 1);
|
||||
this.currentCharIndex++;
|
||||
this.element.classList.add('typing');
|
||||
setTimeout(() => this.type(), this.typingSpeed);
|
||||
} else {
|
||||
// Finished typing current message
|
||||
this.element.classList.remove('typing');
|
||||
this.element.classList.add('paused');
|
||||
setTimeout(() => {
|
||||
this.element.classList.remove('paused');
|
||||
this.isDeleting = true;
|
||||
setTimeout(() => this.type(), this.deleteDelay);
|
||||
}, this.pauseTime);
|
||||
}
|
||||
}
|
||||
|
||||
deleteText() {
|
||||
if (this.currentCharIndex > 0) {
|
||||
const currentMessage = this.messages[this.currentMessageIndex];
|
||||
this.element.textContent = currentMessage.substring(0, this.currentCharIndex - 1);
|
||||
this.currentCharIndex--;
|
||||
setTimeout(() => this.type(), this.deletingSpeed);
|
||||
} else {
|
||||
// Finished deleting, move to next message
|
||||
this.isDeleting = false;
|
||||
this.nextMessage();
|
||||
setTimeout(() => this.type(), this.typingSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
nextMessage() {
|
||||
if (this.messages && this.messages.length > 0) {
|
||||
this.currentMessageIndex = (this.currentMessageIndex + 1) % this.messages.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Method to add new messages dynamically
|
||||
addMessage(message) {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
// Method to update messages array
|
||||
updateMessages(newMessages) {
|
||||
this.messages = newMessages;
|
||||
this.currentMessageIndex = 0;
|
||||
this.currentCharIndex = 0;
|
||||
this.isDeleting = false;
|
||||
}
|
||||
|
||||
// Method to pause/resume typing
|
||||
pause() {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.isPaused = false;
|
||||
this.type();
|
||||
}
|
||||
}
|
||||
|
||||
// Preload emojis to prevent loading issues
|
||||
function preloadEmojis() {
|
||||
const emojis = ['🇺🇸', '🇬🇧', '🇮🇪', '🇫🇷', '🇪🇸', '🇩🇪', '🇮🇹', '🇧🇷', '🇯🇵', '🇨🇳', '🇷🇺', '🇵🇱', '🇸🇪', '🇳🇱', '🇫🇮', '🇳🇴', '🇭🇺', '🇬🇷', '🇮🇱', '🇸🇦', '🇮🇳', '🇰🇷', '🇻🇳', '🇵🇭', '🇮🇩', '🇹🇭', '❤️'];
|
||||
const testDiv = document.createElement('div');
|
||||
testDiv.style.position = 'absolute';
|
||||
testDiv.style.left = '-9999px';
|
||||
testDiv.style.fontSize = '1px';
|
||||
testDiv.innerHTML = emojis.join('');
|
||||
document.body.appendChild(testDiv);
|
||||
setTimeout(() => document.body.removeChild(testDiv), 100);
|
||||
}
|
||||
|
||||
// Initialize typewriter when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Preload emojis first
|
||||
preloadEmojis();
|
||||
|
||||
// Array of multilingual "thanks" messages with flags
|
||||
const messages = [
|
||||
"🇺🇸🇬🇧 Thank you!",
|
||||
"🇮🇪 Go raibh maith agat!",
|
||||
"🇫🇷 Merci!",
|
||||
"🇪🇸 ¡Gracias!",
|
||||
"🇩🇪 Danke!",
|
||||
"🇮🇹 Grazie!",
|
||||
"🇧🇷 Obrigado!",
|
||||
"🇯🇵 ありがとう!",
|
||||
"🇨🇳 谢谢!",
|
||||
"🇷🇺 Спасибо!",
|
||||
"🇵🇱 Tak!",
|
||||
"🇸🇪 Tack!",
|
||||
"🇳🇱 Dank je!",
|
||||
"🇫🇮 Kiitos!",
|
||||
"🇳🇴 Takk!",
|
||||
"🇵🇱 Dziękuję!",
|
||||
"🇭🇺 Köszönöm!",
|
||||
"🇬🇷 Ευχαριστώ!",
|
||||
"🇮🇱 תודה!",
|
||||
"🇸🇦 شكرا!",
|
||||
"🇮🇳 धन्यवाद!",
|
||||
"🇰🇷 감사합니다!",
|
||||
"🇻🇳 Cảm ơn!",
|
||||
"🇵🇭 Salamat!",
|
||||
"🇮🇩 Terima kasih!",
|
||||
"🇹🇭 ขอบคุณ!",
|
||||
"❤️ Much love!"
|
||||
];
|
||||
|
||||
// Initialize the typewriter effect with a small delay to ensure emojis are loaded
|
||||
setTimeout(() => {
|
||||
window.typewriter = new TypewriterEffect('typewriter-text', messages);
|
||||
}, 200);
|
||||
});
|
||||
Reference in New Issue
Block a user