Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>SheetSync Explorer</title> | |
| <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/vue@3.2.31/dist/vue.global.js"></script> | |
| <style> | |
| .fade-enter-active, .fade-leave-active { | |
| transition: opacity 0.3s ease; | |
| } | |
| .fade-enter-from, .fade-leave-to { | |
| opacity: 0; | |
| } | |
| .table-container { | |
| max-height: 70vh; | |
| overflow-y: auto; | |
| } | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #888; | |
| border-radius: 10px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #555; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50"> | |
| <div id="app"> | |
| <header class="bg-gradient-to-r from-indigo-600 to-purple-600 shadow-lg"> | |
| <div class="container mx-auto px-4 py-6"> | |
| <div class="flex justify-between items-center"> | |
| <div class="flex items-center space-x-2"> | |
| <i data-feather="grid" class="text-white w-8 h-8"></i> | |
| <h1 class="text-2xl font-bold text-white">SheetSync Explorer</h1> | |
| </div> | |
| <div class="flex space-x-4"> | |
| <button @click="showConnectDialog = true" class="flex items-center space-x-2 bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-all duration-300"> | |
| <i data-feather="link" class="w-4 h-4"></i> | |
| <span>Connect Sheet</span> | |
| </button> | |
| <button @click="showHelp = true" class="flex items-center space-x-2 bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-all duration-300"> | |
| <i data-feather="help-circle" class="w-4 h-4"></i> | |
| <span>Help</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="container mx-auto px-4 py-8"> | |
| <transition name="fade"> | |
| <div v-if="loading" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> | |
| <div class="bg-white p-6 rounded-lg shadow-xl flex flex-col items-center"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-600 mb-4"></div> | |
| <p class="text-gray-700">Loading your sheet data...</p> | |
| </div> | |
| </div> | |
| </transition> | |
| <transition name="fade"> | |
| <div v-if="showConnectDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> | |
| <div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-lg"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-semibold text-gray-800">Connect Google Sheet</h3> | |
| <button @click="showConnectDialog = false" class="text-gray-500 hover:text-gray-700"> | |
| <i data-feather="x" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Sheet ID</label> | |
| <input v-model="sheetId" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="e.g. 1XqFG..."> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Sheet Name</label> | |
| <input v-model="sheetName" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="e.g. Sheet1"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">API Key (Optional)</label> | |
| <input v-model="apiKey" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Your Google API key"> | |
| </div> | |
| <button @click="fetchData" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2 px-4 rounded-md transition-colors duration-300 flex items-center justify-center space-x-2"> | |
| <i data-feather="download" class="w-4 h-4"></i> | |
| <span>Load Data</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </transition> | |
| <div v-if="data.length > 0" class="bg-white rounded-xl shadow-md overflow-hidden"> | |
| <div class="p-4 border-b border-gray-200 flex justify-between items-center"> | |
| <h2 class="text-xl font-semibold text-gray-800 flex items-center space-x-2"> | |
| <i data-feather="file-text" class="text-indigo-600"></i> | |
| <span>{{ sheetName || 'Your Sheet' }}</span> | |
| </h2> | |
| <div class="flex space-x-2"> | |
| <div class="relative"> | |
| <input v-model="searchQuery" type="text" placeholder="Search..." class="pl-8 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"> | |
| <i data-feather="search" class="absolute left-2 top-2.5 text-gray-400 w-4 h-4"></i> | |
| </div> | |
| <button @click="exportToCSV" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors duration-300"> | |
| <i data-feather="download" class="w-4 h-4"></i> | |
| <span>Export</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="table-container"> | |
| <table class="min-w-full divide-y divide-gray-200"> | |
| <thead class="bg-gray-50 sticky top-0"> | |
| <tr> | |
| <th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer" @click="sortBy(header)"> | |
| <div class="flex items-center"> | |
| {{ header }} | |
| <i data-feather="chevrons-up" v-if="sortColumn === header && sortDirection === 'asc'" class="w-3 h-3 ml-1 text-gray-400"></i> | |
| <i data-feather="chevrons-down" v-if="sortColumn === header && sortDirection === 'desc'" class="w-3 h-3 ml-1 text-gray-400"></i> | |
| </div> | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody class="bg-white divide-y divide-gray-200"> | |
| <tr v-for="(row, rowIndex) in filteredData" :key="rowIndex" :class="{'bg-gray-50': rowIndex % 2 === 0}"> | |
| <td v-for="(value, colIndex) in row" :key="colIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> | |
| {{ value }} | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between"> | |
| <div class="flex-1 flex justify-between sm:hidden"> | |
| <button @click="currentPage = Math.max(1, currentPage - 1)" :disabled="currentPage === 1" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | |
| Previous | |
| </button> | |
| <button @click="currentPage = Math.min(totalPages, currentPage + 1)" :disabled="currentPage === totalPages" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"> | |
| Next | |
| </button> | |
| </div> | |
| <div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> | |
| <div> | |
| <p class="text-sm text-gray-700"> | |
| Showing <span class="font-medium">{{ (currentPage - 1) * pageSize + 1 }}</span> to <span class="font-medium">{{ Math.min(currentPage * pageSize, filteredData.length) }}</span> of <span class="font-medium">{{ filteredData.length }}</span> results | |
| </p> | |
| </div> | |
| <div> | |
| <nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination"> | |
| <button @click="currentPage = Math.max(1, currentPage - 1)" :disabled="currentPage === 1" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> | |
| <span class="sr-only">Previous</span> | |
| <i data-feather="chevron-left" class="h-5 w-5"></i> | |
| </button> | |
| <button v-for="page in visiblePages" :key="page" @click="currentPage = page" :class="{'z-10 bg-indigo-50 border-indigo-500 text-indigo-600': currentPage === page, 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50': currentPage !== page}" class="relative inline-flex items-center px-4 py-2 border text-sm font-medium"> | |
| {{ page }} | |
| </button> | |
| <button @click="currentPage = Math.min(totalPages, currentPage + 1)" :disabled="currentPage === totalPages" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"> | |
| <span class="sr-only">Next</span> | |
| <i data-feather="chevron-right" class="h-5 w-5"></i> | |
| </button> | |
| </nav> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-else class="text-center py-12"> | |
| <div class="mx-auto max-w-md p-6 bg-white rounded-xl shadow-md"> | |
| <div class="flex flex-col items-center"> | |
| <i data-feather="file" class="w-12 h-12 text-gray-400 mb-4"></i> | |
| <h3 class="text-lg font-medium text-gray-900 mb-2">No Sheet Data Loaded</h3> | |
| <p class="text-gray-500 mb-4">Connect to a Google Sheet to view and explore your data</p> | |
| <button @click="showConnectDialog = true" class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition-colors duration-300"> | |
| <i data-feather="link" class="w-4 h-4"></i> | |
| <span>Connect Sheet</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <transition name="fade"> | |
| <div v-if="showHelp" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> | |
| <div class="bg-white p-6 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-y-auto"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-semibold text-gray-800">Help & Instructions</h3> | |
| <button @click="showHelp = false" class="text-gray-500 hover:text-gray-700"> | |
| <i data-feather="x" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4 text-gray-700"> | |
| <div> | |
| <h4 class="font-medium text-gray-900 mb-2">How to Connect Your Google Sheet</h4> | |
| <ol class="list-decimal list-inside space-y-2"> | |
| <li>Open your Google Sheet and look at the URL. The ID is the long string between "/d/" and "/edit". Copy this ID.</li> | |
| <li>Enter the Sheet ID in the connection dialog.</li> | |
| <li>Specify the sheet name (usually "Sheet1" if you haven't renamed it).</li> | |
| <li>For public sheets, you don't need an API key. For private sheets, you'll need to generate a Google API key.</li> | |
| </ol> | |
| </div> | |
| <div> | |
| <h4 class="font-medium text-gray-900 mb-2">Features</h4> | |
| <ul class="list-disc list-inside space-y-2"> | |
| <li><strong>Sorting:</strong> Click on any column header to sort by that column.</li> | |
| <li><strong>Search:</strong> Use the search box to filter rows across all columns.</li> | |
| <li><strong>Pagination:</strong> Navigate through large datasets with the pagination controls.</li> | |
| <li><strong>Export:</strong> Download your filtered data as a CSV file.</li> | |
| </ul> | |
| </div> | |
| <div> | |
| <h4 class="font-medium text-gray-900 mb-2">Example Public Sheet</h4> | |
| <p class="text-sm bg-gray-100 p-2 rounded">Try this sample public sheet:</p> | |
| <div class="bg-gray-50 p-3 rounded mt-2"> | |
| <p class="text-sm"><strong>Sheet ID:</strong> 1XqFG1RzO4Tuo6v3i2e_Ca6Q5u1B2tX7dL0b3J2m3m3k</p> | |
| <p class="text-sm"><strong>Sheet Name:</strong> Sheet1</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </transition> | |
| <footer class="bg-gray-100 border-t border-gray-200 mt-12"> | |
| <div class="container mx-auto px-4 py-6"> | |
| <div class="flex flex-col md:flex-row justify-between items-center"> | |
| <div class="flex items-center space-x-2 mb-4 md:mb-0"> | |
| <i data-feather="grid" class="text-indigo-600 w-6 h-6"></i> | |
| <span class="text-gray-700">SheetSync Explorer</span> | |
| </div> | |
| <div class="flex space-x-6"> | |
| <a href="#" class="text-gray-500 hover:text-indigo-600 transition-colors duration-300"> | |
| <i data-feather="github" class="w-5 h-5"></i> | |
| </a> | |
| <a href="#" class="text-gray-500 hover:text-indigo-600 transition-colors duration-300"> | |
| <i data-feather="twitter" class="w-5 h-5"></i> | |
| </a> | |
| <a href="#" class="text-gray-500 hover:text-indigo-600 transition-colors duration-300"> | |
| <i data-feather="mail" class="w-5 h-5"></i> | |
| </a> | |
| </div> | |
| </div> | |
| <div class="mt-4 text-center md:text-left text-sm text-gray-500"> | |
| <p>© 2023 SheetSync Explorer. All data remains in your browser.</p> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| <script> | |
| const { createApp, ref, computed } = Vue; | |
| createApp({ | |
| setup() { | |
| // State | |
| const sheetId = ref(''); | |
| const sheetName = ref('Sheet1'); | |
| const apiKey = ref(''); | |
| const showConnectDialog = ref(false); | |
| const showHelp = ref(false); | |
| const loading = ref(false); | |
| const data = ref([]); | |
| const headers = ref([]); | |
| const searchQuery = ref(''); | |
| const sortColumn = ref(''); | |
| const sortDirection = ref('asc'); | |
| const currentPage = ref(1); | |
| const pageSize = ref(10); | |
| // Computed properties | |
| const filteredData = computed(() => { | |
| let result = [...data.value]; | |
| // Apply search filter | |
| if (searchQuery.value) { | |
| const query = searchQuery.value.toLowerCase(); | |
| result = result.filter(row => | |
| row.some(cell => | |
| String(cell).toLowerCase().includes(query) | |
| ) | |
| ); | |
| } | |
| // Apply sorting | |
| if (sortColumn.value) { | |
| const columnIndex = headers.value.indexOf(sortColumn.value); | |
| if (columnIndex >= 0) { | |
| result.sort((a, b) => { | |
| const valA = a[columnIndex]; | |
| const valB = b[columnIndex]; | |
| // Try to compare as numbers if possible | |
| const numA = Number(valA); | |
| const numB = Number(valB); | |
| if (!isNaN(numA) && !isNaN(numB)) { | |
| return sortDirection.value === 'asc' ? numA - numB : numB - numA; | |
| } | |
| // Fall back to string comparison | |
| const strA = String(valA).toLowerCase(); | |
| const strB = String(valB).toLowerCase(); | |
| if (strA < strB) return sortDirection.value === 'asc' ? -1 : 1; | |
| if (strA > strB) return sortDirection.value === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| } | |
| } | |
| return result; | |
| }); | |
| const paginatedData = computed(() => { | |
| const start = (currentPage.value - 1) * pageSize.value; | |
| const end = start + pageSize.value; | |
| return filteredData.value.slice(start, end); | |
| }); | |
| const totalPages = computed(() => { | |
| return Math.ceil(filteredData.value.length / pageSize.value); | |
| }); | |
| const visiblePages = computed(() => { | |
| const pages = []; | |
| const maxVisible = 5; | |
| let start = Math.max(1, currentPage.value - Math.floor(maxVisible / 2)); | |
| let end = Math.min(totalPages.value, start + maxVisible - 1); | |
| if (end - start + 1 < maxVisible) { | |
| start = Math.max(1, end - maxVisible + 1); | |
| } | |
| for (let i = start; i <= end; i++) { | |
| pages.push(i); | |
| } | |
| return pages; | |
| }); | |
| // Methods | |
| const fetchData = async () => { | |
| try { | |
| loading.value = true; | |
| // Construct the API URL | |
| let url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId.value}/values/${sheetName.value}?`; | |
| if (apiKey.value) { | |
| url += `key=${apiKey.value}`; | |
| } else { | |
| // For public sheets, we can use a proxy to avoid CORS issues | |
| url = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; | |
| } | |
| const response = await fetch(url); | |
| const json = await response.json(); | |
| // Handle proxy response | |
| const result = apiKey.value ? json : JSON.parse(json.contents); | |
| if (result.values && result.values.length > 0) { | |
| headers.value = result.values[0]; | |
| data.value = result.values.slice(1); | |
| } else { | |
| throw new Error('No data found in the sheet'); | |
| } | |
| showConnectDialog.value = false; | |
| } catch (error) { | |
| console.error('Error fetching sheet data:', error); | |
| alert('Failed to load sheet data. Please check your Sheet ID and try again.'); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const sortBy = (column) => { | |
| if (sortColumn.value === column) { | |
| sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| sortColumn.value = column; | |
| sortDirection.value = 'asc'; | |
| } | |
| currentPage.value = 1; | |
| }; | |
| const exportToCSV = () => { | |
| if (data.value.length === 0) return; | |
| let csv = headers.value.join(',') + '\n'; | |
| filteredData.value.forEach(row => { | |
| csv += row.map(cell => { | |
| // Escape quotes and wrap in quotes if contains comma or quote | |
| if (typeof cell === 'string' && (cell.includes(',') || cell.includes('"'))) { | |
| return `"${cell.replace(/"/g, '""')}"`; | |
| } | |
| return cell; | |
| }).join(',') + '\n'; | |
| }); | |
| const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.setAttribute('download', `${sheetName.value || 'sheet'}_export.csv`); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| // Initialize with an example sheet if needed | |
| const initExample = () => { | |
| if (data.value.length === 0 && !sheetId.value) { | |
| sheetId.value = '1XqFG1RzO4Tuo6v3i2e_Ca6Q5u1B2tX7dL0b3J2m3m3k'; | |
| sheetName.value = 'Sheet1'; | |
| } | |
| }; | |
| initExample(); | |
| return { | |
| sheetId, | |
| sheetName, | |
| apiKey, | |
| showConnectDialog, | |
| showHelp, | |
| loading, | |
| data, | |
| headers, | |
| searchQuery, | |
| sortColumn, | |
| sortDirection, | |
| currentPage, | |
| pageSize, | |
| filteredData, | |
| paginatedData, | |
| totalPages, | |
| visiblePages, | |
| fetchData, | |
| sortBy, | |
| exportToCSV | |
| }; | |
| }, | |
| mounted() { | |
| feather.replace(); | |
| }, | |
| updated() { | |
| feather.replace(); | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |