feat: implement url cleaner tool, local storage persistence and extension integration
This commit is contained in:
425
src/components/tools/UrlCleaner.vue
Normal file
425
src/components/tools/UrlCleaner.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { Copy, Trash2, ExternalLink, Power, Zap, X } from 'lucide-vue-next'
|
||||
import { useExtension } from '../../composables/useExtension'
|
||||
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||
import ExtensionStatus from './common/ExtensionStatus.vue'
|
||||
|
||||
// Extension integration
|
||||
const { isExtensionReady, isListening, lastClipboardText, startListening, stopListening, writeClipboard } = useExtension()
|
||||
|
||||
const inputUrl = ref('')
|
||||
// Use local storage for history persistence
|
||||
const cleanedHistory = useLocalStorage('url-cleaner-history', [])
|
||||
const isWatchEnabled = useLocalStorage('url-cleaner-watch-enabled', false)
|
||||
|
||||
// Watch for clipboard changes from extension
|
||||
watch(lastClipboardText, (newText) => {
|
||||
if (isWatchEnabled.value && newText) {
|
||||
processUrl(newText, true)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync watch state with extension listener
|
||||
watch(isWatchEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
startListening()
|
||||
} else {
|
||||
stopListening()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Re-enable listening when extension becomes ready
|
||||
watch(isExtensionReady, (ready) => {
|
||||
if (ready && isWatchEnabled.value) {
|
||||
startListening()
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle watch mode
|
||||
const toggleWatch = () => {
|
||||
isWatchEnabled.value = !isWatchEnabled.value
|
||||
}
|
||||
|
||||
// Manual clean
|
||||
const handleClean = () => {
|
||||
if (inputUrl.value) {
|
||||
processUrl(inputUrl.value, false)
|
||||
inputUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const processUrl = (text, autoClipboard = false) => {
|
||||
try {
|
||||
// Basic URL validation
|
||||
if (!text.match(/^https?:\/\//i)) {
|
||||
// Not a URL, ignore in watch mode
|
||||
if (autoClipboard) return
|
||||
}
|
||||
|
||||
const originalLength = text.length
|
||||
let cleanedUrl = text
|
||||
|
||||
try {
|
||||
const urlObj = new URL(text)
|
||||
// Remove query params and hash
|
||||
if (urlObj.search || urlObj.hash) {
|
||||
urlObj.search = ''
|
||||
urlObj.hash = ''
|
||||
cleanedUrl = urlObj.toString()
|
||||
// Remove trailing slash if it wasn't there before? usually keep it standard
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL format
|
||||
if (!autoClipboard) {
|
||||
// Show error or just return original
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If no change, ignore in watch mode to avoid loops
|
||||
if (cleanedUrl === text && autoClipboard) {
|
||||
return
|
||||
}
|
||||
|
||||
const newLength = cleanedUrl.length
|
||||
const savedChars = originalLength - newLength
|
||||
const savedPercent = originalLength > 0 ? Math.round((savedChars / originalLength) * 100) : 0
|
||||
|
||||
// Add to history
|
||||
const entry = {
|
||||
id: Date.now(),
|
||||
original: text,
|
||||
cleaned: cleanedUrl,
|
||||
savedPercent,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
}
|
||||
|
||||
cleanedHistory.value.unshift(entry)
|
||||
|
||||
// Limit history
|
||||
if (cleanedHistory.value.length > 50) {
|
||||
cleanedHistory.value.pop()
|
||||
}
|
||||
|
||||
// Auto-copy back to clipboard if in watch mode
|
||||
if (autoClipboard && savedChars > 0) {
|
||||
writeClipboard(cleanedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing URL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const removeEntry = (id) => {
|
||||
cleanedHistory.value = cleanedHistory.value.filter(item => item.id !== id)
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
cleanedHistory.value = []
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isListening.value) {
|
||||
stopListening()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tool-container full-width">
|
||||
<div class="tool-panel">
|
||||
<div class="panel-header">
|
||||
<h2 class="tool-title">URL Cleaner</h2>
|
||||
<ExtensionStatus :isReady="isExtensionReady" />
|
||||
</div>
|
||||
|
||||
<div class="input-section">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="inputUrl"
|
||||
type="text"
|
||||
placeholder="Paste URL here to clean..."
|
||||
class="url-input"
|
||||
@keyup.enter="handleClean"
|
||||
>
|
||||
<button class="btn-neon" @click="handleClean">
|
||||
Clean
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="watch-toggle">
|
||||
<button
|
||||
class="btn-neon toggle-btn"
|
||||
:class="{ 'active': isWatchEnabled && isExtensionReady }"
|
||||
@click="toggleWatch"
|
||||
:disabled="!isExtensionReady"
|
||||
:title="!isExtensionReady ? 'Extension required for auto-watch' : 'Automatically clean URLs from clipboard'"
|
||||
>
|
||||
<Power size="18" />
|
||||
<span>Watch Clipboard</span>
|
||||
<span v-if="isWatchEnabled && isExtensionReady" class="status-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-section" v-if="cleanedHistory.length > 0">
|
||||
<div class="history-header">
|
||||
<h3>Cleaned URLs</h3>
|
||||
<button class="icon-btn" @click="clearHistory" title="Clear History">
|
||||
<Trash2 size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="history-list">
|
||||
<div v-for="item in cleanedHistory" :key="item.id" class="history-item">
|
||||
<div class="item-info">
|
||||
<div class="cleaned-url">{{ item.cleaned }}</div>
|
||||
<div class="meta-info">
|
||||
<span class="timestamp">{{ item.timestamp }}</span>
|
||||
<span class="savings" v-if="item.savedPercent > 0">
|
||||
<Zap size="12" /> -{{ item.savedPercent }}% junk removed
|
||||
</span>
|
||||
<span class="no-change" v-else>No junk found</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="icon-btn" @click="copyToClipboard(item.cleaned)" title="Copy">
|
||||
<Copy size="18" />
|
||||
</button>
|
||||
<a :href="item.cleaned" target="_blank" class="icon-btn" title="Open">
|
||||
<ExternalLink size="18" />
|
||||
</a>
|
||||
<button class="icon-btn delete-btn" @click="removeEntry(item.id)" title="Remove">
|
||||
<X size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-else>
|
||||
<p>Paste a URL above or enable "Watch Clipboard" to automatically clean links.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-container.full-width {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
flex: 1;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--toggle-border);
|
||||
background: var(--toggle-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.url-input:focus {
|
||||
border-color: var(--primary-accent);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-accent-rgb), 0.2);
|
||||
}
|
||||
|
||||
.watch-toggle {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: rgba(var(--primary-accent-rgb), 0.2);
|
||||
border-color: var(--primary-accent);
|
||||
color: var(--primary-accent);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #4ade80; /* Green */
|
||||
box-shadow: 0 0 8px #4ade80;
|
||||
margin-left: 0.2rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.history-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.8rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.cleaned-url {
|
||||
color: var(--primary-accent);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.savings {
|
||||
color: #4ade80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .savings {
|
||||
color: #16a34a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: none;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
:global(:root[data-theme="light"]) .delete-btn:hover {
|
||||
background: none;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user