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>