sheetsync-explorer / index.html
Levohug's picture
can you use data input from google sheets and make it into a user friendly app to access the information in the sheets?
961544c verified
Raw
History Blame Contribute Delete
25.6 kB
<!DOCTYPE html>
<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>