441 lines
24 KiB
Vue
441 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { Head, useForm } from '@inertiajs/vue3';
|
||
import { onMounted, nextTick, ref, computed } from 'vue';
|
||
import CasinoAdminLayout from '@/layouts/admin/CasinoAdminLayout.vue';
|
||
|
||
// Full country list with ISO codes and German names
|
||
const COUNTRIES: { code: string; name: string }[] = [
|
||
{ code: 'AF', name: 'Afghanistan' }, { code: 'AL', name: 'Albanien' }, { code: 'DZ', name: 'Algerien' },
|
||
{ code: 'AD', name: 'Andorra' }, { code: 'AO', name: 'Angola' }, { code: 'AR', name: 'Argentinien' },
|
||
{ code: 'AM', name: 'Armenien' }, { code: 'AU', name: 'Australien' }, { code: 'AT', name: 'Österreich' },
|
||
{ code: 'AZ', name: 'Aserbaidschan' }, { code: 'BS', name: 'Bahamas' }, { code: 'BH', name: 'Bahrain' },
|
||
{ code: 'BD', name: 'Bangladesch' }, { code: 'BY', name: 'Belarus' }, { code: 'BE', name: 'Belgien' },
|
||
{ code: 'BZ', name: 'Belize' }, { code: 'BO', name: 'Bolivien' }, { code: 'BA', name: 'Bosnien' },
|
||
{ code: 'BR', name: 'Brasilien' }, { code: 'BN', name: 'Brunei' }, { code: 'BG', name: 'Bulgarien' },
|
||
{ code: 'KH', name: 'Kambodscha' }, { code: 'CA', name: 'Kanada' }, { code: 'CL', name: 'Chile' },
|
||
{ code: 'CN', name: 'China' }, { code: 'CO', name: 'Kolumbien' }, { code: 'HR', name: 'Kroatien' },
|
||
{ code: 'CU', name: 'Kuba' }, { code: 'CY', name: 'Zypern' }, { code: 'CZ', name: 'Tschechien' },
|
||
{ code: 'DK', name: 'Dänemark' }, { code: 'EG', name: 'Ägypten' }, { code: 'EE', name: 'Estland' },
|
||
{ code: 'ET', name: 'Äthiopien' }, { code: 'FI', name: 'Finnland' }, { code: 'FR', name: 'Frankreich' },
|
||
{ code: 'GE', name: 'Georgien' }, { code: 'DE', name: 'Deutschland' }, { code: 'GH', name: 'Ghana' },
|
||
{ code: 'GR', name: 'Griechenland' }, { code: 'GT', name: 'Guatemala' }, { code: 'HU', name: 'Ungarn' },
|
||
{ code: 'IN', name: 'Indien' }, { code: 'ID', name: 'Indonesien' }, { code: 'IR', name: 'Iran' },
|
||
{ code: 'IQ', name: 'Irak' }, { code: 'IE', name: 'Irland' }, { code: 'IL', name: 'Israel' },
|
||
{ code: 'IT', name: 'Italien' }, { code: 'JP', name: 'Japan' }, { code: 'JO', name: 'Jordanien' },
|
||
{ code: 'KZ', name: 'Kasachstan' }, { code: 'KE', name: 'Kenia' }, { code: 'KW', name: 'Kuwait' },
|
||
{ code: 'LV', name: 'Lettland' }, { code: 'LB', name: 'Libanon' }, { code: 'LY', name: 'Libyen' },
|
||
{ code: 'LT', name: 'Litauen' }, { code: 'LU', name: 'Luxemburg' }, { code: 'MY', name: 'Malaysia' },
|
||
{ code: 'MT', name: 'Malta' }, { code: 'MX', name: 'Mexiko' }, { code: 'MD', name: 'Moldawien' },
|
||
{ code: 'MC', name: 'Monaco' }, { code: 'ME', name: 'Montenegro' }, { code: 'MA', name: 'Marokko' },
|
||
{ code: 'NL', name: 'Niederlande' }, { code: 'NZ', name: 'Neuseeland' }, { code: 'NG', name: 'Nigeria' },
|
||
{ code: 'NO', name: 'Norwegen' }, { code: 'OM', name: 'Oman' }, { code: 'PK', name: 'Pakistan' },
|
||
{ code: 'PE', name: 'Peru' }, { code: 'PH', name: 'Philippinen' }, { code: 'PL', name: 'Polen' },
|
||
{ code: 'PT', name: 'Portugal' }, { code: 'QA', name: 'Katar' }, { code: 'RO', name: 'Rumänien' },
|
||
{ code: 'RU', name: 'Russland' }, { code: 'SA', name: 'Saudi-Arabien' }, { code: 'RS', name: 'Serbien' },
|
||
{ code: 'SG', name: 'Singapur' }, { code: 'SK', name: 'Slowakei' }, { code: 'SI', name: 'Slowenien' },
|
||
{ code: 'ZA', name: 'Südafrika' }, { code: 'KR', name: 'Südkorea' }, { code: 'ES', name: 'Spanien' },
|
||
{ code: 'LK', name: 'Sri Lanka' }, { code: 'SE', name: 'Schweden' }, { code: 'CH', name: 'Schweiz' },
|
||
{ code: 'SY', name: 'Syrien' }, { code: 'TW', name: 'Taiwan' }, { code: 'TH', name: 'Thailand' },
|
||
{ code: 'TN', name: 'Tunesien' }, { code: 'TR', name: 'Türkei' }, { code: 'UA', name: 'Ukraine' },
|
||
{ code: 'AE', name: 'Vereinigte Arabische Emirate' }, { code: 'GB', name: 'Vereinigtes Königreich' },
|
||
{ code: 'US', name: 'USA' }, { code: 'UY', name: 'Uruguay' }, { code: 'UZ', name: 'Usbekistan' },
|
||
{ code: 'VE', name: 'Venezuela' }, { code: 'VN', name: 'Vietnam' }, { code: 'YE', name: 'Jemen' },
|
||
{ code: 'ZW', name: 'Simbabwe' },
|
||
];
|
||
|
||
const countryMap: Record<string, string> = Object.fromEntries(COUNTRIES.map(c => [c.code, c.name]));
|
||
function getCountryName(code: string): string { return countryMap[code] ?? code; }
|
||
|
||
const props = defineProps<{
|
||
settings: {
|
||
enabled: boolean;
|
||
mode: 'blacklist' | 'whitelist';
|
||
blocked_countries: string[];
|
||
allowed_countries: string[];
|
||
vpn_block: boolean;
|
||
vpn_provider: 'none' | 'ipqualityscore' | 'proxycheck';
|
||
vpn_api_key: string;
|
||
block_message: string;
|
||
redirect_url: string;
|
||
};
|
||
}>();
|
||
|
||
const form = useForm({
|
||
enabled: props.settings.enabled ?? false,
|
||
mode: props.settings.mode ?? 'blacklist',
|
||
blocked_countries: [...(props.settings.blocked_countries ?? [])],
|
||
allowed_countries: [...(props.settings.allowed_countries ?? [])],
|
||
vpn_block: props.settings.vpn_block ?? false,
|
||
vpn_provider: props.settings.vpn_provider ?? 'none',
|
||
vpn_api_key: props.settings.vpn_api_key ?? '',
|
||
block_message: props.settings.block_message ?? 'This service is not available in your region.',
|
||
redirect_url: props.settings.redirect_url ?? '',
|
||
});
|
||
|
||
const newBlocked = ref('');
|
||
const newAllowed = ref('');
|
||
const blockedSearch = ref('');
|
||
const allowedSearch = ref('');
|
||
|
||
const filteredBlockedCountries = computed(() => {
|
||
const q = blockedSearch.value.toLowerCase();
|
||
return COUNTRIES.filter(c =>
|
||
!form.blocked_countries.includes(c.code) &&
|
||
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
|
||
);
|
||
});
|
||
|
||
const filteredAllowedCountries = computed(() => {
|
||
const q = allowedSearch.value.toLowerCase();
|
||
return COUNTRIES.filter(c =>
|
||
!form.allowed_countries.includes(c.code) &&
|
||
(c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q))
|
||
);
|
||
});
|
||
|
||
function addCountry(list: 'blocked' | 'allowed') {
|
||
const code = (list === 'blocked' ? newBlocked.value : newAllowed.value).trim().toUpperCase();
|
||
if (!code || code.length !== 2) return;
|
||
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
|
||
if (!arr.includes(code)) arr.push(code);
|
||
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
|
||
else { newAllowed.value = ''; allowedSearch.value = ''; }
|
||
}
|
||
|
||
function addCountryFromSelect(list: 'blocked' | 'allowed', code: string) {
|
||
if (!code) return;
|
||
const arr = list === 'blocked' ? form.blocked_countries : form.allowed_countries;
|
||
if (!arr.includes(code)) arr.push(code);
|
||
if (list === 'blocked') { newBlocked.value = ''; blockedSearch.value = ''; }
|
||
else { newAllowed.value = ''; allowedSearch.value = ''; }
|
||
}
|
||
|
||
function removeCountry(list: 'blocked' | 'allowed', code: string) {
|
||
if (list === 'blocked') {
|
||
form.blocked_countries = form.blocked_countries.filter(c => c !== code);
|
||
} else {
|
||
form.allowed_countries = form.allowed_countries.filter(c => c !== code);
|
||
}
|
||
}
|
||
|
||
function submit() {
|
||
form.post('/admin/settings/geo', { preserveScroll: true });
|
||
}
|
||
|
||
onMounted(() => {
|
||
nextTick(() => { if ((window as any).lucide) (window as any).lucide.createIcons(); });
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<CasinoAdminLayout>
|
||
<Head title="Admin – VPN & GeoBlock" />
|
||
|
||
<template #title>VPN & GeoBlock</template>
|
||
|
||
<div class="page-wrap">
|
||
<!-- Flash -->
|
||
<div v-if="($page.props as any).flash?.success" class="alert-success">
|
||
<i data-lucide="check-circle"></i>
|
||
{{ ($page.props as any).flash.success }}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div class="card-title-group">
|
||
<h2>GeoBlock & VPN-Schutz</h2>
|
||
<p class="card-subtitle">Steuere den Zugang nach Land und blockiere VPN-Nutzer.</p>
|
||
</div>
|
||
<button class="btn-primary" @click="submit" :disabled="form.processing">
|
||
<i data-lucide="save"></i>
|
||
Speichern
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Master Toggle -->
|
||
<div class="section">
|
||
<div class="toggle-row">
|
||
<div>
|
||
<div class="toggle-label">GeoBlock aktivieren</div>
|
||
<div class="toggle-desc">Wenn deaktiviert, haben alle Länder Zugang.</div>
|
||
</div>
|
||
<button class="toggle-btn" :class="{ active: form.enabled }" @click="form.enabled = !form.enabled">
|
||
<span class="toggle-knob"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div :class="{ 'dimmed': !form.enabled }">
|
||
<!-- Mode -->
|
||
<div class="section">
|
||
<label class="field-label">Modus</label>
|
||
<div class="mode-grid">
|
||
<label class="mode-card" :class="{ selected: form.mode === 'blacklist' }">
|
||
<input type="radio" v-model="form.mode" value="blacklist" class="hidden">
|
||
<i data-lucide="shield-x"></i>
|
||
<div>
|
||
<div class="mode-name">Blacklist</div>
|
||
<div class="mode-desc">Alle Länder erlaubt, <b>außer</b> den gesperrten.</div>
|
||
</div>
|
||
</label>
|
||
<label class="mode-card" :class="{ selected: form.mode === 'whitelist' }">
|
||
<input type="radio" v-model="form.mode" value="whitelist" class="hidden">
|
||
<i data-lucide="shield-check"></i>
|
||
<div>
|
||
<div class="mode-name">Whitelist</div>
|
||
<div class="mode-desc">Nur <b>erlaubte</b> Länder haben Zugang.</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Blocked Countries -->
|
||
<div class="section" v-if="form.mode === 'blacklist'">
|
||
<label class="field-label">Gesperrte Länder</label>
|
||
<div class="country-picker-row">
|
||
<div class="country-search-wrap">
|
||
<input type="text" v-model="blockedSearch" placeholder="Land suchen..." class="field-input country-search-input">
|
||
<div class="country-dropdown" v-if="blockedSearch.length >= 1">
|
||
<button
|
||
v-for="c in filteredBlockedCountries.slice(0, 8)"
|
||
:key="c.code"
|
||
class="country-option"
|
||
@click.prevent="addCountryFromSelect('blocked', c.code)"
|
||
>
|
||
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
|
||
<span class="option-name">{{ c.name }}</span>
|
||
<span class="option-code">{{ c.code }}</span>
|
||
</button>
|
||
<p v-if="filteredBlockedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tags" v-if="form.blocked_countries.length">
|
||
<span v-for="c in form.blocked_countries" :key="c" class="tag tag-red">
|
||
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
|
||
<span class="tag-name">{{ getCountryName(c) }}</span>
|
||
<span class="tag-code">{{ c }}</span>
|
||
<button @click="removeCountry('blocked', c)"><i data-lucide="x"></i></button>
|
||
</span>
|
||
</div>
|
||
<p class="field-hint" v-else>Noch keine Länder gesperrt.</p>
|
||
</div>
|
||
|
||
<!-- Allowed Countries -->
|
||
<div class="section" v-if="form.mode === 'whitelist'">
|
||
<label class="field-label">Erlaubte Länder</label>
|
||
<div class="country-picker-row">
|
||
<div class="country-search-wrap">
|
||
<input type="text" v-model="allowedSearch" placeholder="Land suchen..." class="field-input country-search-input">
|
||
<div class="country-dropdown" v-if="allowedSearch.length >= 1">
|
||
<button
|
||
v-for="c in filteredAllowedCountries.slice(0, 8)"
|
||
:key="c.code"
|
||
class="country-option"
|
||
@click.prevent="addCountryFromSelect('allowed', c.code)"
|
||
>
|
||
<img :src="`https://flagcdn.com/20x15/${c.code.toLowerCase()}.png`" :alt="c.code" class="option-flag">
|
||
<span class="option-name">{{ c.name }}</span>
|
||
<span class="option-code">{{ c.code }}</span>
|
||
</button>
|
||
<p v-if="filteredAllowedCountries.length === 0" class="no-results">Kein Land gefunden.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tags" v-if="form.allowed_countries.length">
|
||
<span v-for="c in form.allowed_countries" :key="c" class="tag tag-green">
|
||
<img :src="`https://flagcdn.com/16x12/${c.toLowerCase()}.png`" :alt="c" class="flag-img" @error="(e:any)=>e.target.style.display='none'">
|
||
<span class="tag-name">{{ getCountryName(c) }}</span>
|
||
<span class="tag-code">{{ c }}</span>
|
||
<button @click="removeCountry('allowed', c)"><i data-lucide="x"></i></button>
|
||
</span>
|
||
</div>
|
||
<p class="field-hint" v-else>Noch keine Länder erlaubt.</p>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<!-- VPN Block -->
|
||
<div class="section">
|
||
<div class="toggle-row">
|
||
<div>
|
||
<div class="toggle-label">VPN/Proxy blockieren</div>
|
||
<div class="toggle-desc">Nutzer mit VPN oder Proxy werden gesperrt.</div>
|
||
</div>
|
||
<button class="toggle-btn" :class="{ active: form.vpn_block }" @click="form.vpn_block = !form.vpn_block">
|
||
<span class="toggle-knob"></span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="form.vpn_block" class="vpn-settings">
|
||
<div class="form-row">
|
||
<div class="form-col">
|
||
<label class="field-label">VPN-Anbieter</label>
|
||
<select v-model="form.vpn_provider" class="field-input">
|
||
<option value="none">Kein (deaktiviert)</option>
|
||
<option value="ipqualityscore">IPQualityScore</option>
|
||
<option value="proxycheck">ProxyCheck.io</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-col" v-if="form.vpn_provider !== 'none'">
|
||
<label class="field-label">API-Schlüssel</label>
|
||
<input type="text" v-model="form.vpn_api_key" placeholder="API Key..." class="field-input">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="divider"></div>
|
||
|
||
<!-- Block Message & Redirect -->
|
||
<div class="section">
|
||
<div class="form-row">
|
||
<div class="form-col full">
|
||
<label class="field-label">Sperr-Nachricht</label>
|
||
<textarea v-model="form.block_message" rows="3" class="field-input" placeholder="Diese Nachricht wird gesperrten Nutzern angezeigt."></textarea>
|
||
</div>
|
||
<div class="form-col full">
|
||
<label class="field-label">Weiterleitungs-URL <span class="optional">(optional)</span></label>
|
||
<input type="url" v-model="form.redirect_url" placeholder="https://example.com/gesperrt" class="field-input">
|
||
<p class="field-hint">Wenn angegeben, werden gesperrte Nutzer dorthin weitergeleitet statt eine Fehlermeldung zu sehen.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-foot">
|
||
<button class="btn-primary" @click="submit" :disabled="form.processing">
|
||
<i data-lucide="save"></i>
|
||
{{ form.processing ? 'Wird gespeichert...' : 'Einstellungen speichern' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CasinoAdminLayout>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-wrap { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||
|
||
.alert-success {
|
||
background: rgba(0, 200, 100, 0.1); border: 1px solid rgba(0, 200, 100, 0.3);
|
||
color: #00c864; padding: 12px 16px; border-radius: 10px;
|
||
display: flex; align-items: center; gap: 10px; font-weight: 600;
|
||
}
|
||
.alert-success i { width: 18px; height: 18px; flex-shrink: 0; }
|
||
|
||
.card { background: #0f0f10; border: 1px solid #18181b; border-radius: 14px; overflow: hidden; }
|
||
.card-head {
|
||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||
padding: 24px; border-bottom: 1px solid #18181b;
|
||
}
|
||
.card-title-group h2 { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||
.card-subtitle { color: #71717a; font-size: 13px; margin: 0; }
|
||
.card-foot { padding: 20px 24px; border-top: 1px solid #18181b; display: flex; justify-content: flex-end; }
|
||
|
||
.section { padding: 20px 24px; }
|
||
.divider { height: 1px; background: #18181b; margin: 0; }
|
||
.dimmed { opacity: 0.4; pointer-events: none; }
|
||
|
||
/* Toggle */
|
||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||
.toggle-label { font-weight: 600; color: #e4e4e7; font-size: 14px; }
|
||
.toggle-desc { color: #71717a; font-size: 12px; margin-top: 2px; }
|
||
.toggle-btn {
|
||
width: 48px; height: 26px; border-radius: 99px; background: #27272a; border: none; cursor: pointer;
|
||
position: relative; transition: background 0.2s; flex-shrink: 0;
|
||
}
|
||
.toggle-btn.active { background: #df006a; }
|
||
.toggle-knob {
|
||
position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
|
||
background: #fff; border-radius: 50%; transition: transform 0.2s;
|
||
}
|
||
.toggle-btn.active .toggle-knob { transform: translateX(22px); }
|
||
|
||
/* Mode cards */
|
||
.mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
|
||
.mode-card {
|
||
display: flex; align-items: flex-start; gap: 14px; padding: 16px;
|
||
background: #18181b; border: 2px solid #27272a; border-radius: 10px;
|
||
cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.mode-card:hover { border-color: #3f3f46; }
|
||
.mode-card.selected { border-color: #df006a; background: rgba(223,0,106,0.06); }
|
||
.mode-card i { width: 20px; height: 20px; color: #71717a; flex-shrink: 0; margin-top: 2px; }
|
||
.mode-card.selected i { color: #df006a; }
|
||
.mode-name { font-weight: 700; font-size: 14px; color: #e4e4e7; }
|
||
.mode-desc { font-size: 12px; color: #71717a; margin-top: 2px; }
|
||
|
||
/* Fields */
|
||
.field-label { display: block; font-size: 12px; font-weight: 700; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||
.field-input {
|
||
width: 100%; background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||
color: #e4e4e7; padding: 10px 14px; font-size: 13px; transition: border-color 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.field-input:focus { outline: none; border-color: #df006a; }
|
||
.code-input { width: 100px; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; }
|
||
.field-hint { font-size: 11px; color: #52525b; margin-top: 6px; }
|
||
.optional { font-weight: 400; color: #52525b; text-transform: none; letter-spacing: 0; }
|
||
|
||
/* Country picker */
|
||
.country-picker-row { margin-bottom: 12px; }
|
||
.country-search-wrap { position: relative; }
|
||
.country-search-input { width: 100%; }
|
||
.country-dropdown {
|
||
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 100;
|
||
background: #18181b; border: 1px solid #3f3f46; border-radius: 10px;
|
||
overflow: hidden; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
}
|
||
.country-option {
|
||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||
padding: 9px 14px; background: transparent; border: none;
|
||
color: #e4e4e7; cursor: pointer; text-align: left; transition: background 0.1s;
|
||
}
|
||
.country-option:hover { background: rgba(223,0,106,0.1); }
|
||
.option-flag { width: 20px; height: 15px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||
.option-name { flex: 1; font-size: 13px; }
|
||
.option-code { font-size: 11px; color: #71717a; font-weight: 700; letter-spacing: 1px; }
|
||
.no-results { color: #52525b; font-size: 12px; padding: 10px 14px; margin: 0; }
|
||
|
||
.tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.tag {
|
||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 6px;
|
||
font-size: 12px; font-weight: 600;
|
||
}
|
||
.tag-name { font-size: 12px; }
|
||
.tag-code { font-size: 10px; opacity: 0.6; letter-spacing: 1px; font-weight: 700; }
|
||
.tag button { background: transparent; border: none; cursor: pointer; display: flex; align-items: center; padding: 0; }
|
||
.tag button i { width: 12px; height: 12px; }
|
||
.flag-img { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||
.tag-red { background: rgba(255,62,62,0.1); color: #ff3e3e; border: 1px solid rgba(255,62,62,0.25); }
|
||
.tag-red button { color: #ff3e3e; }
|
||
.tag-green { background: rgba(0,200,100,0.1); color: #00c864; border: 1px solid rgba(0,200,100,0.25); }
|
||
.tag-green button { color: #00c864; }
|
||
|
||
/* VPN Settings */
|
||
.vpn-settings { margin-top: 16px; }
|
||
|
||
/* Form layout */
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||
.form-col.full { grid-column: 1 / -1; }
|
||
|
||
/* Primary button */
|
||
.btn-primary {
|
||
display: inline-flex; align-items: center; gap: 8px;
|
||
background: #df006a; border: none; color: #fff; padding: 10px 20px;
|
||
border-radius: 8px; font-weight: 700; font-size: 13px; cursor: pointer; transition: all 0.15s;
|
||
}
|
||
.btn-primary:hover { background: #b8005a; }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.btn-primary i { width: 16px; height: 16px; }
|
||
|
||
.hidden { display: none; }
|
||
|
||
@media (max-width: 640px) {
|
||
.mode-grid { grid-template-columns: 1fr; }
|
||
.form-row { grid-template-columns: 1fr; }
|
||
.card-head { flex-direction: column; }
|
||
}
|
||
</style>
|