bsc

Comprehensive codebase and cou...
Log | Files | Refs | Activity | README | LICENSE

root / docs / .vitepress / theme / components / ExternalLinkWarning.vue

ExternalLinkWarning.vue (6699B)


      1 <script setup lang="ts">
      2 import { ref, onMounted, onUnmounted } from 'vue'
      3 
      4 const isVisible = ref(false)
      5 const pendingUrl = ref('')
      6 const copied = ref(false)
      7 
      8 const safeDomains = [
      9   'amit.is-a.dev',
     10   'github.com',
     11   'raw.githubusercontent.com',
     12   'raw.usercontent.amit.is-a.dev',
     13   // Technically already covered by the 'amit.is-a.dev' entry above (this
     14   // is a subdomain of it), but listed explicitly so it stays safe even if
     15   // that broader entry is ever narrowed or removed later.
     16   'raw.cdn.amit.is-a.dev'
     17 ]
     18 
     19 const lockScroll = () => {
     20   document.body.style.overflow = 'hidden'
     21 }
     22 
     23 const unlockScroll = () => {
     24   document.body.style.overflow = ''
     25 }
     26 
     27 const reset = () => {
     28   isVisible.value = false
     29   pendingUrl.value = ''
     30   copied.value = false
     31   unlockScroll()
     32 }
     33 
     34 const handleGlobalClick = (e: MouseEvent) => {
     35   const target = (e.target as HTMLElement).closest('a')
     36   if (!target || !target.href) return
     37   try {
     38     const url = new URL(target.href)
     39     if (url.protocol !== 'http:' && url.protocol !== 'https:') return
     40     const isExternal = url.hostname !== window.location.hostname
     41     const isSafe = safeDomains.some(domain => url.hostname.endsWith(domain))
     42     if (isExternal && !isSafe) {
     43       if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return
     44       e.preventDefault()
     45       pendingUrl.value = target.href
     46       isVisible.value = true
     47       lockScroll()
     48     }
     49   } catch {}
     50 }
     51 
     52 onMounted(() => document.addEventListener('click', handleGlobalClick))
     53 onUnmounted(() => {
     54   document.removeEventListener('click', handleGlobalClick)
     55   unlockScroll()
     56 })
     57 
     58 const openLink = () => {
     59   window.open(pendingUrl.value, '_blank', 'noopener,noreferrer')
     60   reset()
     61 }
     62 
     63 const copyUrl = async () => {
     64   await navigator.clipboard.writeText(pendingUrl.value)
     65   copied.value = true
     66   setTimeout(() => { copied.value = false }, 1500)
     67 }
     68 </script>
     69 
     70 <template>
     71   <Teleport to="body">
     72     <Transition name="fade">
     73       <div v-if="isVisible" class="overlay" @click="reset"></div>
     74     </Transition>
     75 
     76     <Transition name="sheet">
     77       <div v-if="isVisible" class="sheet" role="dialog" aria-modal="true">
     78         <div class="handle"></div>
     79 
     80         <p class="warning">
     81           <strong>You're about to leave this site.</strong><br />
     82           This link leads to an external site I don't control, and can't vouch for its content. Continue anyway?
     83         </p>
     84 
     85         <div class="url-box">
     86           <span class="url-text">{{ pendingUrl }}</span>
     87           <button class="copy-icon-btn" @click="copyUrl" :aria-label="copied ? 'Copied' : 'Copy link'" :title="copied ? 'Copied' : 'Copy link'">
     88             <svg v-if="!copied" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
     89               <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
     90               <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
     91             </svg>
     92             <svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
     93               <polyline points="20 6 9 17 4 12"/>
     94             </svg>
     95           </button>
     96         </div>
     97 
     98         <div class="actions">
     99           <button class="btn btn-no" @click="reset">
    100             No
    101           </button>
    102 
    103           <button class="btn btn-open" @click="openLink">
    104             Continue
    105           </button>
    106         </div>
    107       </div>
    108     </Transition>
    109   </Teleport>
    110 </template>
    111 
    112 <style scoped>
    113 .overlay {
    114   position: fixed;
    115   inset: 0;
    116   background: rgba(0, 0, 0, 0.35);
    117   backdrop-filter: blur(6px);
    118   -webkit-backdrop-filter: blur(6px);
    119   z-index: 99998;
    120 }
    121 
    122 .sheet {
    123   position: fixed;
    124   left: 0;
    125   right: 0;
    126   bottom: 0;
    127   z-index: 99999;
    128   width: 100%;
    129   max-width: 420px;
    130   margin: 0 auto;
    131   background: var(--vp-c-bg);
    132   border: 1px solid rgba(255, 255, 255, 0.18);
    133   border-radius: 16px 16px 0 0;
    134   padding: 10px 20px calc(20px + env(safe-area-inset-bottom, 0px));
    135   box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.15);
    136 }
    137 
    138 .handle {
    139   width: 36px;
    140   height: 4px;
    141   border-radius: 2px;
    142   background: var(--vp-c-divider);
    143   margin: 0 auto 16px;
    144 }
    145 
    146 .warning {
    147   font-size: 0.875rem;
    148   line-height: 1.5;
    149   color: var(--vp-c-text-2);
    150   margin: 0 0 14px;
    151   text-align: center;
    152 }
    153 
    154 .warning strong {
    155   color: var(--vp-c-text-1);
    156   font-size: 0.95rem;
    157 }
    158 
    159 .url-box {
    160   display: flex;
    161   align-items: center;
    162   gap: 8px;
    163   font-size: 0.8rem;
    164   font-family: var(--vp-font-family-mono);
    165   color: var(--vp-c-text-1);
    166   background: var(--vp-c-bg-alt);
    167   border-radius: 8px;
    168   padding: 10px 10px 10px 12px;
    169   margin-bottom: 16px;
    170   line-height: 1.4;
    171 }
    172 
    173 .url-text {
    174   flex: 1;
    175   min-width: 0;
    176   word-break: break-all;
    177 }
    178 
    179 .copy-icon-btn {
    180   flex-shrink: 0;
    181   display: flex;
    182   align-items: center;
    183   justify-content: center;
    184   width: 30px;
    185   height: 30px;
    186   border-radius: 6px;
    187   border: none;
    188   background: transparent;
    189   color: var(--vp-c-text-2);
    190   cursor: pointer;
    191   transition: background 0.15s, color 0.15s;
    192 }
    193 
    194 .copy-icon-btn:hover {
    195   background: var(--vp-c-bg);
    196   color: var(--vp-c-text-1);
    197 }
    198 
    199 .copy-icon-btn:active {
    200   opacity: 0.75;
    201 }
    202 
    203 .actions {
    204   display: flex;
    205   gap: 10px;
    206 }
    207 
    208 .btn {
    209   flex: 1;
    210   display: flex;
    211   align-items: center;
    212   justify-content: center;
    213   gap: 6px;
    214   padding: 11px 12px;
    215   border-radius: 8px;
    216   border: none;
    217   font-size: 0.875rem;
    218   font-weight: 500;
    219   cursor: pointer;
    220   transition: opacity 0.15s;
    221 }
    222 
    223 .btn:active {
    224   opacity: 0.75;
    225 }
    226 
    227 .btn-no {
    228   background: var(--vp-c-bg-alt);
    229   color: var(--vp-c-text-1);
    230 }
    231 
    232 .btn-open {
    233   background: var(--vp-c-brand-1);
    234   color: #fff;
    235 }
    236 
    237 /* Larger screens: centered rounded card instead of a bottom sheet */
    238 @media (min-width: 640px) {
    239   .sheet {
    240     left: 50%;
    241     right: auto;
    242     bottom: auto;
    243     top: 50%;
    244     transform: translate(-50%, -50%);
    245     width: 100%;
    246     max-width: 400px;
    247     border-radius: 16px;
    248     border: 1px solid rgba(255, 255, 255, 0.18);
    249     padding: 24px;
    250     box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
    251   }
    252 
    253   .handle {
    254     display: none;
    255   }
    256 
    257   .warning {
    258     text-align: left;
    259   }
    260 
    261   .sheet-enter-active,
    262   .sheet-leave-active {
    263     transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
    264   }
    265   .sheet-enter-from,
    266   .sheet-leave-to {
    267     opacity: 0;
    268     transform: translate(-50%, -46%);
    269   }
    270 }
    271 
    272 /* Transitions */
    273 .fade-enter-active,
    274 .fade-leave-active {
    275   transition: opacity 0.2s ease;
    276 }
    277 .fade-enter-from,
    278 .fade-leave-to {
    279   opacity: 0;
    280 }
    281 
    282 .sheet-enter-active {
    283   transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
    284 }
    285 .sheet-leave-active {
    286   transition: transform 0.2s ease;
    287 }
    288 .sheet-enter-from,
    289 .sheet-leave-to {
    290   transform: translateY(100%);
    291 }
    292 </style>
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror