ExternalLinkWarning.vue (5789B)
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 ] 14 15 const reset = () => { 16 isVisible.value = false 17 pendingUrl.value = '' 18 copied.value = false 19 } 20 21 const handleGlobalClick = (e: MouseEvent) => { 22 const target = (e.target as HTMLElement).closest('a') 23 if (!target || !target.href) return 24 try { 25 const url = new URL(target.href) 26 if (url.protocol !== 'http:' && url.protocol !== 'https:') return 27 const isExternal = url.hostname !== window.location.hostname 28 const isSafe = safeDomains.some(domain => url.hostname.endsWith(domain)) 29 if (isExternal && !isSafe) { 30 if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return 31 e.preventDefault() 32 pendingUrl.value = target.href 33 isVisible.value = true 34 } 35 } catch {} 36 } 37 38 onMounted(() => document.addEventListener('click', handleGlobalClick)) 39 onUnmounted(() => document.removeEventListener('click', handleGlobalClick)) 40 41 const openLink = () => { 42 window.open(pendingUrl.value, '_blank', 'noopener,noreferrer') 43 reset() 44 } 45 46 const copyUrl = async () => { 47 await navigator.clipboard.writeText(pendingUrl.value) 48 copied.value = true 49 setTimeout(() => { copied.value = false }, 1500) 50 } 51 </script> 52 53 <template> 54 <Teleport to="body"> 55 <Transition name="fade"> 56 <div v-if="isVisible" class="overlay" @click="reset"></div> 57 </Transition> 58 59 <Transition name="sheet"> 60 <div v-if="isVisible" class="sheet" role="dialog" aria-modal="true"> 61 <div class="handle"></div> 62 63 <p class="warning"> 64 This link will take you outside the website. I am not responsible for the content of external sites. 65 </p> 66 67 <div class="url-box">{{ pendingUrl }}</div> 68 69 <div class="actions"> 70 <button class="btn btn-copy" @click="copyUrl"> 71 <svg v-if="!copied" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 72 <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> 73 <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> 74 </svg> 75 <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 76 <polyline points="20 6 9 17 4 12"/> 77 </svg> 78 {{ copied ? 'Copied' : 'Copy Link' }} 79 </button> 80 81 <button class="btn btn-open" @click="openLink"> 82 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 83 <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/> 84 <polyline points="15 3 21 3 21 9"/> 85 <line x1="10" y1="14" x2="21" y2="3"/> 86 </svg> 87 Open Link 88 </button> 89 </div> 90 </div> 91 </Transition> 92 </Teleport> 93 </template> 94 95 <style scoped> 96 .overlay { 97 position: fixed; 98 inset: 0; 99 background: rgba(0, 0, 0, 0.35); 100 backdrop-filter: blur(6px); 101 -webkit-backdrop-filter: blur(6px); 102 z-index: 99998; 103 } 104 105 .sheet { 106 position: fixed; 107 left: 0; 108 right: 0; 109 bottom: 0; 110 z-index: 99999; 111 width: 100%; 112 max-width: 420px; 113 margin: 0 auto; 114 background: var(--vp-c-bg); 115 border: 1px solid rgba(255, 255, 255, 0.18); 116 border-radius: 16px 16px 0 0; 117 padding: 10px 20px calc(20px + env(safe-area-inset-bottom, 0px)); 118 box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.15); 119 } 120 121 .handle { 122 width: 36px; 123 height: 4px; 124 border-radius: 2px; 125 background: var(--vp-c-divider); 126 margin: 0 auto 16px; 127 } 128 129 .warning { 130 font-size: 0.875rem; 131 line-height: 1.5; 132 color: var(--vp-c-text-2); 133 margin: 0 0 14px; 134 text-align: center; 135 } 136 137 .url-box { 138 font-size: 0.8rem; 139 font-family: var(--vp-font-family-mono); 140 color: var(--vp-c-text-1); 141 background: var(--vp-c-bg-alt); 142 border-radius: 8px; 143 padding: 10px 12px; 144 margin-bottom: 16px; 145 word-break: break-all; 146 line-height: 1.4; 147 } 148 149 .actions { 150 display: flex; 151 gap: 10px; 152 } 153 154 .btn { 155 flex: 1; 156 display: flex; 157 align-items: center; 158 justify-content: center; 159 gap: 6px; 160 padding: 11px 12px; 161 border-radius: 8px; 162 border: none; 163 font-size: 0.875rem; 164 font-weight: 500; 165 cursor: pointer; 166 transition: opacity 0.15s; 167 } 168 169 .btn:active { 170 opacity: 0.75; 171 } 172 173 .btn-copy { 174 background: var(--vp-c-bg-alt); 175 color: var(--vp-c-text-1); 176 } 177 178 .btn-open { 179 background: var(--vp-c-brand-1); 180 color: #fff; 181 } 182 183 /* Larger screens: centered rounded card instead of a bottom sheet */ 184 @media (min-width: 640px) { 185 .sheet { 186 left: 50%; 187 right: auto; 188 bottom: auto; 189 top: 50%; 190 transform: translate(-50%, -50%); 191 width: 100%; 192 max-width: 400px; 193 border-radius: 16px; 194 border: 1px solid rgba(255, 255, 255, 0.18); 195 padding: 24px; 196 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); 197 } 198 199 .handle { 200 display: none; 201 } 202 203 .warning { 204 text-align: left; 205 } 206 207 .sheet-enter-active, 208 .sheet-leave-active { 209 transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1); 210 } 211 .sheet-enter-from, 212 .sheet-leave-to { 213 opacity: 0; 214 transform: translate(-50%, -46%); 215 } 216 } 217 218 /* Transitions */ 219 .fade-enter-active, 220 .fade-leave-active { 221 transition: opacity 0.2s ease; 222 } 223 .fade-enter-from, 224 .fade-leave-to { 225 opacity: 0; 226 } 227 228 .sheet-enter-active { 229 transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); 230 } 231 .sheet-leave-active { 232 transition: transform 0.2s ease; 233 } 234 .sheet-enter-from, 235 .sheet-leave-to { 236 transform: translateY(100%); 237 } 238 </style>