12 Commits

Author SHA1 Message Date
4c2d423715 0.4.0
All checks were successful
Deploy to Production / deploy (push) Successful in 12s
2026-02-27 06:14:51 +00:00
204aeda00c feat: implement url cleaner tool, local storage persistence and extension integration 2026-02-27 06:14:43 +00:00
7d989be27f feat: url cleaner tool and extension update 2026-02-27 06:11:12 +00:00
efe23a99ac 0.3.5
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 04:50:57 +00:00
bebb63c1de feat: improve PWA update mechanism with visibility check 2026-02-27 04:50:39 +00:00
98d76e3a35 0.3.4
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 04:44:34 +00:00
cc7e80a807 feat: improve mobile layout for Password Generator button 2026-02-27 04:43:58 +00:00
1f5500f7d7 0.3.3
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 04:37:29 +00:00
d404370027 fix: adjust layout for Password Generator and Clipboard Sniffer on smaller screens 2026-02-27 04:37:21 +00:00
8fb3ee1069 refactor: align privacy policy layout with app design
All checks were successful
Deploy to Production / deploy (push) Successful in 7s
2026-02-27 04:20:29 +00:00
348c78612d 0.3.2
All checks were successful
Deploy to Production / deploy (push) Successful in 13s
2026-02-27 04:16:17 +00:00
dc99dce485 fix: restore dark mode styles and scope privacy policy styles 2026-02-27 04:16:01 +00:00
18 changed files with 1030 additions and 417 deletions

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ dist-ssr
*.sw? *.sw?
dev-dist dev-dist
extension-release.zip extension-release.zip
*.zip
tools-app-extension-*.zip

View File

@@ -122,6 +122,17 @@ chrome.runtime.onMessage.addListener(async (request, sender, sendResponse) => {
return true; return true;
} }
if (request.action === 'writeClipboard') {
if (isSniffing) {
chrome.runtime.sendMessage({
target: 'offscreen',
type: 'write-clipboard',
data: request.content
}).catch(() => {});
}
return true;
}
if (request.type === 'clipboard-data' && request.target === 'background') { if (request.type === 'clipboard-data' && request.target === 'background') {
// Received data from offscreen document // Received data from offscreen document
if (isSniffing && request.data && request.data !== lastClipboardContent) { if (isSniffing && request.data && request.data !== lastClipboardContent) {

View File

@@ -46,6 +46,17 @@ window.addEventListener('message', (event) => {
// ignore // ignore
} }
} }
if (event.data.type === 'TOOLS_APP_CLIPBOARD_WRITE') {
try {
chrome.runtime.sendMessage({
action: 'writeClipboard',
content: event.data.content
});
} catch (e) {
console.warn('Tools App Extension: Write clipboard failed', e);
}
}
}); });
// Listen for messages from the Extension Background // Listen for messages from the Extension Background

View File

@@ -1,10 +1,11 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Tools App Extension", "name": "Tools App Extension",
"version": "1.0", "version": "1.1",
"description": "Browser extension for Tools App", "description": "Browser extension for Tools App",
"permissions": [ "permissions": [
"clipboardRead", "clipboardRead",
"clipboardWrite",
"offscreen", "offscreen",
"storage", "storage",
"alarms", "alarms",

View File

@@ -42,12 +42,24 @@ setInterval(async () => {
} }
}, 50); }, 50);
// Listen for messages from background if we need to change behavior // Listen for messages from background
chrome.runtime.onMessage.addListener((message) => { chrome.runtime.onMessage.addListener((message) => {
if (message.target === 'offscreen') { if (message.target === 'offscreen') {
// Handle commands // Handle commands
if (message.type === 'play-sound') { if (message.type === 'play-sound') {
playNotificationSound(); playNotificationSound();
} else if (message.type === 'write-clipboard') {
try {
const text = message.data;
if (text) {
textEl.value = text;
textEl.select();
document.execCommand('copy');
lastText = text; // Update internal state to avoid re-triggering update
}
} catch (e) {
console.error('Failed to write clipboard:', e);
}
} }
} }
}); });

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "tools-app", "name": "tools-app",
"version": "0.3.1", "version": "0.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tools-app", "name": "tools-app",
"version": "0.3.1", "version": "0.4.0",
"dependencies": { "dependencies": {
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"vue": "^3.5.25", "vue": "^3.5.25",

View File

@@ -1,7 +1,7 @@
{ {
"name": "tools-app", "name": "tools-app",
"private": true, "private": true,
"version": "0.3.1", "version": "0.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -5,6 +5,7 @@ import Header from './components/Header.vue'
import Footer from './components/Footer.vue' import Footer from './components/Footer.vue'
import Sidebar from './components/Sidebar.vue' import Sidebar from './components/Sidebar.vue'
import InstallPrompt from './components/InstallPrompt.vue' import InstallPrompt from './components/InstallPrompt.vue'
import ReloadPrompt from './components/ReloadPrompt.vue'
const isSidebarOpen = ref(window.innerWidth >= 768) const isSidebarOpen = ref(window.innerWidth >= 768)
const router = useRouter() const router = useRouter()
@@ -53,6 +54,7 @@ onUnmounted(() => {
</div> </div>
<Footer /> <Footer />
<InstallPrompt /> <InstallPrompt />
<ReloadPrompt />
</template> </template>
<style scoped> <style scoped>

View File

@@ -0,0 +1,94 @@
<script setup>
import { useRegisterSW } from 'virtual:pwa-register/vue'
import { watch, onMounted } from 'vue'
// Zmieniamy na autoUpdate w configu, więc tutaj tylko nasłuchujemy i ewentualnie wymuszamy
const {
needRefresh,
updateServiceWorker,
} = useRegisterSW({
immediate: true,
onRegistered(r) {
// Sprawdzaj aktualizacje co godzinę
r && setInterval(() => {
r.update()
}, 60 * 60 * 1000)
}
})
const updateSW = async () => {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
console.log('Checking for SW update...')
await registration.update()
} catch (e) {
console.error('Failed to update SW:', e)
}
}
}
onMounted(() => {
// Check on load
updateSW()
// Check when app becomes visible again (e.g. switching tabs/apps)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
updateSW()
}
})
})
</script>
<template>
<div
v-if="needRefresh"
class="pwa-toast"
role="alert"
>
<div class="message">
New content available, click on reload button to update.
</div>
<button @click="updateServiceWorker()">
Reload
</button>
</div>
</template>
<style scoped>
.pwa-toast {
position: fixed;
right: 0;
bottom: 0;
margin: 16px;
padding: 12px;
border: 1px solid var(--glass-border);
border-radius: 4px;
z-index: 10000;
text-align: left;
box-shadow: var(--glass-shadow);
background-color: var(--glass-bg);
backdrop-filter: blur(10px);
color: var(--text-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.pwa-toast button {
border: 1px solid var(--glass-border);
outline: none;
margin-right: 5px;
border-radius: 2px;
padding: 3px 10px;
cursor: pointer;
background: var(--button-bg);
color: var(--text-color);
transition: all 0.2s;
}
.pwa-toast button:hover {
background: var(--button-hover-bg);
}
</style>

View File

@@ -12,6 +12,7 @@ defineProps({
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link to="/passwords" class="nav-item" v-ripple>Passwords</router-link> <router-link to="/passwords" class="nav-item" v-ripple>Passwords</router-link>
<router-link to="/clipboard-sniffer" class="nav-item" v-ripple>Clipboard Sniffer</router-link> <router-link to="/clipboard-sniffer" class="nav-item" v-ripple>Clipboard Sniffer</router-link>
<router-link to="/url-cleaner" class="nav-item" v-ripple>URL Cleaner</router-link>
</nav> </nav>
</aside> </aside>
</template> </template>

View File

@@ -1,91 +1,29 @@
<script setup> <script setup>
import { ref, onUnmounted, nextTick, onMounted } from 'vue' import { ref, watch, nextTick } from 'vue'
import { useFillHeight } from '../../composables/useFillHeight' import { useFillHeight } from '../../composables/useFillHeight'
import { Plug, Info, X } from 'lucide-vue-next' import { useExtension } from '../../composables/useExtension'
import { useLocalStorage } from '../../composables/useLocalStorage'
import ExtensionStatus from './common/ExtensionStatus.vue'
const clipboardContent = ref('') const clipboardContent = useLocalStorage('clipboard-sniffer-content', '')
const isListening = ref(false)
const lastClipboardText = ref('')
const textareaRef = ref(null) const textareaRef = ref(null)
const isExtensionReady = ref(false)
const showExtensionModal = ref(false)
let intervalId = null
let extensionCheckInterval = null
const { height: textareaHeight } = useFillHeight(textareaRef, 40) // 40px margin bottom const {
isExtensionReady,
isListening,
lastClipboardText,
startListening,
stopListening
} = useExtension()
// Listen for extension messages const { height: textareaHeight } = useFillHeight(textareaRef, 40)
const handleExtensionMessage = (event) => {
if (event.source !== window) return
if (event.data.type === 'TOOLS_APP_EXTENSION_READY' || event.data.type === 'TOOLS_APP_PONG') { // Watch for clipboard updates from extension
isExtensionReady.value = true watch(lastClipboardText, (newText) => {
lastPongTime = Date.now() if (newText) {
// console.log('Extension is ready') clipboardContent.value += (clipboardContent.value ? '\n' : '') + newText
scrollToBottom()
} }
if (event.data.type === 'TOOLS_APP_CLIPBOARD_UPDATE' && isListening.value) {
const text = event.data.content
if (text && text !== lastClipboardText.value) {
lastClipboardText.value = text
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
scrollToBottom()
}
}
}
const closeModalOnEsc = (e) => {
if (e.key === 'Escape' && showExtensionModal.value) {
showExtensionModal.value = false
}
}
// Watchdog for extension
let lastPongTime = Date.now()
const PING_INTERVAL = 200
const TIMEOUT_THRESHOLD = 500
const startExtensionWatchdog = () => {
extensionCheckInterval = setInterval(() => {
// 1. Send Ping
window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
// 2. Check timeout
// If current time - lastPongTime > threshold, then disconnected
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
isExtensionReady.value = false
}
}, PING_INTERVAL)
}
// Wrapper to intercept PONG and update heartbeat
const messageListener = (event) => {
if (event.source !== window) return
if (event.data.type === 'TOOLS_APP_PONG' || event.data.type === 'TOOLS_APP_EXTENSION_READY') {
lastPongTime = Date.now()
isExtensionReady.value = true
}
handleExtensionMessage(event)
}
onMounted(() => {
window.addEventListener('message', messageListener)
window.addEventListener('keydown', closeModalOnEsc)
// Initial check
window.postMessage({ type: 'TOOLS_APP_INIT' }, '*')
// Start heartbeat
startExtensionWatchdog()
})
onUnmounted(() => {
stopListening()
if (extensionCheckInterval) clearInterval(extensionCheckInterval)
window.removeEventListener('message', messageListener)
window.removeEventListener('keydown', closeModalOnEsc)
}) })
const scrollToBottom = () => { const scrollToBottom = () => {
@@ -97,98 +35,23 @@ const scrollToBottom = () => {
}) })
} }
const startListening = async () => { const copyToClipboard = () => {
try { if (clipboardContent.value) {
isListening.value = true navigator.clipboard.writeText(clipboardContent.value)
// Try native API first (for web app usage without extension)
// Initial read
try {
const text = await navigator.clipboard.readText()
if (text) {
lastClipboardText.value = text
clipboardContent.value += (clipboardContent.value ? '\n' : '') + text
scrollToBottom()
}
} catch (e) {
console.log('Native clipboard read failed (expected if not focused), relying on extension if available')
}
// If extension is ready, ask it to start sniffing
if (isExtensionReady.value) {
window.postMessage({ type: 'TOOLS_APP_START_SNIFFING' }, '*')
}
// Fallback polling for web app (only works when focused usually)
intervalId = setInterval(async () => {
try {
const currentText = await navigator.clipboard.readText()
if (currentText && currentText !== lastClipboardText.value) {
lastClipboardText.value = currentText
clipboardContent.value += (clipboardContent.value ? '\n' : '') + currentText
scrollToBottom()
}
} catch (err) {
// Ignore errors in polling (e.g. lost focus)
}
}, 1000)
} catch (err) {
console.error('Permission denied or clipboard error:', err)
alert('Clipboard access denied. Please allow clipboard access to use this tool.')
}
}
const stopListening = () => {
isListening.value = false
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
if (isExtensionReady.value) {
window.postMessage({ type: 'TOOLS_APP_STOP_SNIFFING' }, '*')
} }
} }
const clearText = () => { const clearText = () => {
clipboardContent.value = '' clipboardContent.value = ''
// Don't reset lastClipboardText so if they copy the same thing again it's detected?
// No, if they clear, they might want to see it again if they copy it again.
// But usually "change" means diff from clipboard.
// If I clear text, but clipboard still has "A", and I copy "A" again (refresh clipboard), readText still returns "A".
// So it won't be detected as a change.
// If user wants to capture "A" again, they need to copy something else then "A".
// That's standard behavior for "sniffer" (detect changes).
} }
const copyToClipboard = async () => {
if (!clipboardContent.value) return
try {
await navigator.clipboard.writeText(clipboardContent.value)
} catch (err) {
console.error('Failed to copy to clipboard:', err)
}
}
onUnmounted(() => {
stopListening()
})
</script> </script>
<template> <template>
<div class="tool-container" style="max-width: 100%;"> <div class="tool-container full-width">
<div class="tool-panel"> <div class="tool-panel">
<div class="tool-header"> <div class="tool-header">
<h2 class="tool-title">Clipboard Sniffer</h2> <h2 class="tool-title">Clipboard Sniffer</h2>
<div <ExtensionStatus :isReady="isExtensionReady" />
class="extension-status"
:class="{ 'connected': isExtensionReady }"
@click="showExtensionModal = true"
:title="isExtensionReady ? 'Extension connected' : 'Extension not detected - Click for info'"
>
<Plug v-if="isExtensionReady" size="20" />
<Info v-else size="20" />
</div>
</div> </div>
<div class="controls"> <div class="controls">
@@ -196,6 +59,8 @@ onUnmounted(() => {
v-if="!isListening" v-if="!isListening"
class="btn-neon" class="btn-neon"
@click="startListening" @click="startListening"
:disabled="!isExtensionReady"
:title="!isExtensionReady ? 'Extension required' : 'Start capturing clipboard'"
v-ripple v-ripple
> >
Start Sniffing Start Sniffing
@@ -224,49 +89,10 @@ onUnmounted(() => {
v-model="clipboardContent" v-model="clipboardContent"
class="tool-textarea" class="tool-textarea"
placeholder="Clipboard content will appear here line by line..." placeholder="Clipboard content will appear here line by line..."
readonly
></textarea> ></textarea>
</div> </div>
</div> </div>
</div> </div>
<!-- Extension Info Modal -->
<div v-if="showExtensionModal" class="modal-overlay" @click.self="showExtensionModal = false">
<div class="modal-content glass-panel">
<button class="close-btn" @click="showExtensionModal = false">
<X size="24" />
</button>
<div v-if="!isExtensionReady">
<h3>Enhance Your Experience</h3>
<p>
Install our browser extension to enable <strong>background clipboard sniffing</strong>!
</p>
<p class="description">
Without the extension, this tool can only capture clipboard content when the tab is active.
With the extension, you can capture content even when you're working in other apps.
</p>
<div class="modal-actions">
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a>
</div>
</div>
<div v-else>
<h3>Extension Connected!</h3>
<p>
You have successfully enabled <strong>background clipboard sniffing</strong>.
</p>
<p class="description">
The extension is active and monitoring your clipboard in the background.
You can now switch to other apps and copy text - it will appear here automatically.
</p>
<div class="modal-actions">
<button class="btn-neon" @click="showExtensionModal = false">Got it!</button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
@@ -279,145 +105,43 @@ onUnmounted(() => {
width: 100%; width: 100%;
} }
.extension-status {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
}
:global(:root[data-theme="light"]) .extension-status {
background: rgba(0, 0, 0, 0.05);
color: #666;
}
.extension-status:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--text-color);
}
:global(:root[data-theme="light"]) .extension-status:hover {
background: rgba(0, 0, 0, 0.1);
color: #000;
}
.extension-status.connected {
color: #4ade80; /* Green for connected */
cursor: pointer; /* Allow clicking to see status */
}
/* :global(:root[data-theme="light"]) .extension-status.connected {
color: #16a34a;
} */
.extension-status.connected:hover {
background: rgba(74, 222, 128, 0.1);
}
/* :global(:root[data-theme="light"]) .extension-status.connected:hover {
background: rgba(22, 163, 74, 0.1);
} */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
position: relative;
width: 90%;
max-width: 500px;
padding: 2rem;
border-radius: 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
text-align: center;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-color);
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.5rem;
color: var(--text-color);
}
.modal-content p {
margin-bottom: 1rem;
line-height: 1.6;
color: var(--text-color);
}
.description {
color: var(--text-secondary) !important;
font-size: 0.9rem;
margin-bottom: 2rem !important;
}
.modal-actions {
display: flex;
justify-content: center;
}
.controls { .controls {
display: flex; display: flex;
gap: 1rem;
justify-content: center; justify-content: center;
gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.btn-neon { .tool-container.full-width {
padding: 0.75rem 1.5rem; max-width: 100%;
min-width: 120px; height: 100%;
display: flex;
flex-direction: column;
} }
.btn-neon.active { .tool-panel {
background: rgba(255, 0, 0, 0.2); display: flex;
border-color: rgba(255, 0, 0, 0.5); flex-direction: column;
box-shadow: 0 0 15px rgba(255, 0, 0, 0.3); height: 100%;
gap: 1.5rem;
} }
.tool-textarea { .tool-textarea {
width: 100%;
height: 100%; height: 100%;
padding: 1rem;
background-color: var(--toggle-bg);
border: 1px solid var(--toggle-border);
border-radius: 8px;
color: var(--text-color);
font-family: monospace;
resize: none;
font-size: 0.9rem;
line-height: 1.5;
}
.tool-textarea:focus {
outline: none;
border-color: var(--primary-accent);
} }
</style> </style>

View File

@@ -75,7 +75,7 @@ const generatePasswords = () => {
<div class="tool-panel"> <div class="tool-panel">
<div class="panel-header"> <div class="panel-header">
<h2 class="tool-title">Bulk Passwords Generator</h2> <h2 class="tool-title">Bulk Passwords Generator</h2>
<div class="action-area"> <div class="action-area desktop-only">
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple> <button class="btn-neon generate-btn" @click="generatePasswords" v-ripple>
Generate Generate
</button> </button>
@@ -131,6 +131,12 @@ const generatePasswords = () => {
</div> </div>
</div> </div>
<div class="mobile-only" style="margin-top: 1rem; width: 100%;">
<button class="btn-neon generate-btn" @click="generatePasswords" v-ripple style="width: 100%;">
Generate
</button>
</div>
<div class="result-area" :style="{ height: textareaHeight }"> <div class="result-area" :style="{ height: textareaHeight }">
<textarea <textarea
class="tool-textarea" class="tool-textarea"
@@ -189,9 +195,10 @@ const generatePasswords = () => {
.inputs-group { .inputs-group {
display: flex; display: flex;
gap: 2rem; gap: 1rem;
flex: 1; flex: 1;
min-width: 300px; min-width: 200px;
flex-wrap: wrap;
} }
.input-wrapper { .input-wrapper {
@@ -199,6 +206,7 @@ const generatePasswords = () => {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
flex: 1; flex: 1;
min-width: 140px;
} }
.checkbox-label { .checkbox-label {
@@ -351,6 +359,14 @@ const generatePasswords = () => {
letter-spacing: 1px; letter-spacing: 1px;
} }
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.options-grid { .options-grid {
flex-direction: column; flex-direction: column;
@@ -370,5 +386,13 @@ const generatePasswords = () => {
.generate-btn { .generate-btn {
width: 100%; width: 100%;
} }
.desktop-only {
display: none;
}
.mobile-only {
display: block !important;
}
} }
</style> </style>

View 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>

View File

@@ -0,0 +1,190 @@
<script setup>
import { ref } from 'vue'
import { Plug, Plus, X } from 'lucide-vue-next'
const props = defineProps({
isReady: Boolean
})
const showModal = ref(false)
</script>
<template>
<div class="extension-status" :class="{ 'is-ready': isReady }" @click="showModal = true" :title="isReady ? 'Extension Connected' : 'Extension Not Connected'">
<Plug v-if="isReady" size="18" />
<Plus v-else size="18" />
</div>
<Teleport to="body">
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal-content glass-panel">
<button class="close-btn" @click="showModal = false">
<X size="24" />
</button>
<div v-if="!isReady">
<h3>Enhance Your Experience</h3>
<p>
Install our browser extension to enable <strong>background clipboard features</strong>!
</p>
<p class="description">
Without the extension, this tool can only access clipboard when the tab is active.
With the extension, you can capture and process content even when you're working in other apps.
</p>
<div class="modal-actions">
<a href="#" class="btn-neon" @click.prevent>Extension Coming Soon</a>
</div>
</div>
<div v-else>
<h3>Extension Connected!</h3>
<p>
You have successfully enabled <strong>background clipboard integration</strong>.
</p>
<p class="description">
The extension is active and ready to process your clipboard in the background.
</p>
<div class="modal-actions">
<button class="btn-neon" @click="showModal = false">Got it!</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.extension-status {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
}
:global(:root[data-theme="light"]) .extension-status {
background: rgba(0, 0, 0, 0.05);
color: var(--text-secondary);
}
.extension-status:hover {
background: rgba(255, 255, 255, 0.2);
color: var(--primary-accent);
}
:global(:root[data-theme="light"]) .extension-status:hover {
background: rgba(0, 0, 0, 0.1);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
padding: 1rem;
}
.modal-content {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 2.5rem; /* Większy padding */
max-width: 500px;
width: 90%;
position: relative;
box-shadow: var(--glass-shadow);
text-align: center;
color: var(--text-color); /* Wymuś kolor tekstu */
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--text-color);
}
h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.8rem; /* Większy nagłówek */
background: var(--title-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
}
p {
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 1rem;
color: var(--text-color); /* Wymuś kolor */
}
.description {
color: var(--text-secondary);
margin-bottom: 2rem;
font-size: 1rem;
}
.modal-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
}
.modal-actions .btn-neon {
font-size: 1.1rem;
padding: 0.8rem 2.5rem;
min-width: 140px;
}
strong {
color: var(--primary-accent);
font-weight: 600;
}
.extension-status.is-ready {
color: #4ade80;
background: rgba(74, 222, 128, 0.1);
}
.extension-status.is-ready:hover {
background: rgba(74, 222, 128, 0.2);
color: #4ade80;
}
:global(:root[data-theme="light"]) .extension-status.is-ready {
background: rgba(74, 222, 128, 0.15);
color: #16a34a; /* Darker green for light mode */
}
</style>

View File

@@ -0,0 +1,79 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useExtension() {
const isExtensionReady = ref(false)
const isListening = ref(false)
const lastClipboardText = ref('')
let extensionCheckInterval = null
let lastPongTime = Date.now()
const PING_INTERVAL = 200
const TIMEOUT_THRESHOLD = 1000
const handleMessage = (event) => {
if (event.source !== window) return
if (event.data.type === 'TOOLS_APP_EXTENSION_READY' || event.data.type === 'TOOLS_APP_PONG') {
isExtensionReady.value = true
lastPongTime = Date.now()
}
if (event.data.type === 'TOOLS_APP_CLIPBOARD_UPDATE') {
const text = event.data.content
// Only update if listening
if (isListening.value && text) {
lastClipboardText.value = text
}
}
}
const startWatchdog = () => {
extensionCheckInterval = setInterval(() => {
window.postMessage({ type: 'TOOLS_APP_PING' }, '*')
if (Date.now() - lastPongTime > TIMEOUT_THRESHOLD) {
isExtensionReady.value = false
}
}, PING_INTERVAL)
}
const startListening = () => {
window.postMessage({ type: 'TOOLS_APP_START_SNIFFING' }, '*')
isListening.value = true
}
const stopListening = () => {
window.postMessage({ type: 'TOOLS_APP_STOP_SNIFFING' }, '*')
isListening.value = false
}
const writeClipboard = (text) => {
// Send to extension (background -> offscreen -> clipboard)
window.postMessage({ type: 'TOOLS_APP_CLIPBOARD_WRITE', content: text }, '*')
// Update local state to avoid echo loop if we are listening
lastClipboardText.value = text
}
onMounted(() => {
window.addEventListener('message', handleMessage)
// Initial check
window.postMessage({ type: 'TOOLS_APP_INIT' }, '*')
startWatchdog()
})
onUnmounted(() => {
if (isListening.value) {
stopListening()
}
if (extensionCheckInterval) clearInterval(extensionCheckInterval)
window.removeEventListener('message', handleMessage)
})
return {
isExtensionReady,
isListening,
lastClipboardText,
startListening,
stopListening,
writeClipboard
}
}

View File

@@ -0,0 +1,18 @@
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
// Initialize state from local storage or default value
const storedValue = localStorage.getItem(key)
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
// Watch for changes and update local storage
watch(data, (newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
}, { deep: true })
return data
}

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Main from '../components/Main.vue' import Main from '../components/Main.vue'
import Passwords from '../components/tools/Passwords.vue' import Passwords from '../components/tools/Passwords.vue'
import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue' import ClipboardSniffer from '../components/tools/ClipboardSniffer.vue'
import UrlCleaner from '../components/tools/UrlCleaner.vue'
import PrivacyPolicy from '../views/PrivacyPolicy.vue' import PrivacyPolicy from '../views/PrivacyPolicy.vue'
const routes = [ const routes = [
@@ -20,6 +21,11 @@ const routes = [
name: 'ClipboardSniffer', name: 'ClipboardSniffer',
component: ClipboardSniffer component: ClipboardSniffer
}, },
{
path: '/url-cleaner',
name: 'UrlCleaner',
component: UrlCleaner
},
{ {
path: '/extension-privacy-policy', path: '/extension-privacy-policy',
name: 'PrivacyPolicy', name: 'PrivacyPolicy',

View File

@@ -3,8 +3,8 @@ import { ArrowLeft } from 'lucide-vue-next'
</script> </script>
<template> <template>
<div class="privacy-container"> <div class="tool-container">
<div class="privacy-content"> <div class="tool-panel privacy-panel">
<header class="privacy-header"> <header class="privacy-header">
<router-link to="/" class="back-link"> <router-link to="/" class="back-link">
<ArrowLeft size="20" /> <ArrowLeft size="20" />
@@ -14,96 +14,102 @@ import { ArrowLeft } from 'lucide-vue-next'
<p class="last-updated">Last Updated: February 27, 2026</p> <p class="last-updated">Last Updated: February 27, 2026</p>
</header> </header>
<section> <div class="privacy-body">
<h2>1. Introduction</h2> <section>
<p> <h2>1. Introduction</h2>
Welcome to Tools App ("we," "our," or "us"). We are committed to protecting your privacy. <p>
This Privacy Policy explains how our Chrome Extension ("Tools App Extension") handles your data. Welcome to Tools App ("we," "our," or "us"). We are committed to protecting your privacy.
</p> This Privacy Policy explains how our Chrome Extension ("Tools App Extension") handles your data.
</section> </p>
</section>
<section> <section>
<h2>2. Data Collection and Usage</h2> <h2>2. Data Collection and Usage</h2>
<p> <p>
The Tools App Extension is designed with privacy as a priority. The Tools App Extension is designed with privacy as a priority.
<strong>We do not collect, store, or transmit any of your personal data to external servers.</strong> <strong>We do not collect, store, or transmit any of your personal data to external servers.</strong>
</p> </p>
<h3>Clipboard Data</h3>
<p>
The extension requires the <code>clipboardRead</code> permission to function.
It reads text from your clipboard <strong>only</strong> when you explicitly enable the "Clipboard Sniffer" tool in the Tools App web interface.
</p>
<ul>
<li>Clipboard data is processed locally within your browser.</li>
<li>Data is sent directly from the extension to the open Tools App tab via a secure local communication channel.</li>
<li>Once you close the tab or stop the tool, the extension stops monitoring the clipboard immediately.</li>
<li>We do not have access to your clipboard history, and it is never uploaded to any cloud storage or third-party service.</li>
</ul>
</section>
<section> <h3>Clipboard Data</h3>
<h2>3. Permissions</h2> <p>
<p>The extension requests the following permissions for specific functional purposes:</p> The extension requires the <code>clipboardRead</code> permission to function.
<ul> It reads text from your clipboard <strong>only</strong> when you explicitly enable the "Clipboard Sniffer" tool in the Tools App web interface.
<li><strong>clipboardRead:</strong> To detect copied text when the Sniffer tool is active.</li> </p>
<li><strong>scripting:</strong> To communicate with the Tools App web page.</li> <ul>
<li><strong>storage:</strong> To save local user preferences (e.g., sound settings).</li> <li>Clipboard data is processed locally within your browser.</li>
<li><strong>alarms:</strong> To maintain the background process active during monitoring sessions.</li> <li>Data is sent directly from the extension to the open Tools App tab via a secure local communication channel.</li>
</ul> <li>Once you close the tab or stop the tool, the extension stops monitoring the clipboard immediately.</li>
</section> <li>We do not have access to your clipboard history, and it is never uploaded to any cloud storage or third-party service.</li>
</ul>
</section>
<section> <section>
<h2>4. Third-Party Services</h2> <h2>3. Permissions</h2>
<p> <p>The extension requests the following permissions for specific functional purposes:</p>
Our extension operates independently and does not use any third-party analytics, tracking scripts, or advertising networks. <ul>
</p> <li><strong>clipboardRead:</strong> To detect copied text when the Sniffer tool is active.</li>
</section> <li><strong>scripting:</strong> To communicate with the Tools App web page.</li>
<li><strong>storage:</strong> To save local user preferences (e.g., sound settings).</li>
<li><strong>alarms:</strong> To maintain the background process active during monitoring sessions.</li>
</ul>
</section>
<section> <section>
<h2>5. Changes to This Policy</h2> <h2>4. Third-Party Services</h2>
<p> <p>
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. Our extension operates independently and does not use any third-party analytics, tracking scripts, or advertising networks.
</p> </p>
</section> </section>
<section> <section>
<h2>6. Contact Us</h2> <h2>5. Changes to This Policy</h2>
<p> <p>
If you have any questions about this Privacy Policy, please contact us via the repository or support channels provided in the Chrome Web Store listing. We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
</p> </p>
</section> </section>
<section>
<h2>6. Contact Us</h2>
<p>
If you have any questions about this Privacy Policy, please contact us via the repository or support channels provided in the Chrome Web Store listing.
</p>
</section>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.privacy-container { .privacy-panel {
min-height: 100vh; max-width: 900px; /* Slightly wider for reading */
width: 100%; margin: 0 auto;
display: flex;
justify-content: center;
padding: 2rem;
background: var(--bg-gradient);
color: var(--text-color);
} }
.privacy-content { .privacy-body {
width: 100%; overflow-y: auto;
max-width: 800px; padding-right: 0.5rem;
background: var(--glass-bg); }
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); /* Custom scrollbar for privacy body */
border: 1px solid var(--glass-border); .privacy-body::-webkit-scrollbar {
border-radius: 16px; width: 8px;
padding: 3rem; }
box-shadow: var(--glass-shadow);
.privacy-body::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.privacy-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
} }
.privacy-header { .privacy-header {
margin-bottom: 3rem; margin-bottom: 2rem;
border-bottom: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border);
padding-bottom: 2rem; padding-bottom: 1.5rem;
flex-shrink: 0;
} }
.back-link { .back-link {
@@ -128,6 +134,7 @@ h1 {
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
width: fit-content;
} }
.last-updated { .last-updated {
@@ -168,29 +175,35 @@ li {
color: var(--text-color); color: var(--text-color);
} }
strong {
color: var(--text-strong);
font-weight: 600;
}
code { code {
background: rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.4rem; padding: 0.2rem 0.4rem;
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
font-size: 0.9em; font-size: 0.9em;
color: var(--text-strong);
} }
:global(:root[data-theme="dark"]) code { :global(html[data-theme="light"]) code {
background: rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.1);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.privacy-container { .privacy-container {
padding: 1rem; padding: 1rem;
} }
.privacy-content { .privacy-content {
padding: 1.5rem; padding: 1.5rem;
} }
h1 { h1 {
font-size: 2rem; font-size: 2rem;
} }
} }
</style> </style>