PWA
This commit is contained in:
parent
9cde9b5cf7
commit
f3927b97a4
21 changed files with 8672 additions and 202 deletions
89
dev-dist/sw.js
Normal file
89
dev-dist/sw.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-54d0af47'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "index.html",
|
||||
"revision": "0.9f4l9mpl4ng"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3391
dev-dist/workbox-54d0af47.js
Normal file
3391
dev-dist/workbox-54d0af47.js
Normal file
File diff suppressed because it is too large
Load diff
14
index.html
14
index.html
|
|
@ -2,9 +2,17 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AIO</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Atitlán Directory</title>
|
||||
<link rel="apple-touch-icon" href="/pwa-192x192.png">
|
||||
<link rel="apple-touch-startup-image" href="/splash.png">
|
||||
<meta name="apple-mobile-web-app-title" content="Atitlán">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
4844
package-lock.json
generated
4844
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
"tailwindcss": "^4.0.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
3
public/mask-icon.svg
Normal file
3
public/mask-icon.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 3L4 14h7l-2 7 9-11h-7l2-7z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
BIN
public/pwa-192x192.png
Normal file
BIN
public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/pwa-512x512.png
Normal file
BIN
public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
BIN
public/splash.png
Normal file
BIN
public/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
44
src/App.vue
44
src/App.vue
|
|
@ -1,29 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import Navbar from './components/layout/Navbar.vue'
|
||||
import Footer from './components/layout/Footer.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import Navbar from '@/components/layout/Navbar.vue'
|
||||
import Footer from '@/components/layout/Footer.vue'
|
||||
|
||||
// Initialize theme
|
||||
useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-background">
|
||||
<Navbar />
|
||||
<main class="flex-1">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
<Footer />
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<nav class="container flex h-14 items-center">
|
||||
<Navbar />
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vue:hover {
|
||||
filter: drop-shadow(0 0 2em #42b883aa);
|
||||
}
|
||||
<style>
|
||||
/* Remove default styles */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,89 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
|
||||
:root {
|
||||
/* Catppuccin Latte */
|
||||
--color-background: #eff1f5;
|
||||
--color-foreground: #4c4f69;
|
||||
--color-card: #e6e9ef;
|
||||
--color-card-foreground: #4c4f69;
|
||||
--color-popover: #e6e9ef;
|
||||
--color-popover-foreground: #4c4f69;
|
||||
--color-primary: #1e66f5;
|
||||
--color-primary-foreground: #eff1f5;
|
||||
--color-secondary: #ccd0da;
|
||||
--color-secondary-foreground: #4c4f69;
|
||||
--color-muted: #ccd0da;
|
||||
--color-muted-foreground: #6c6f85;
|
||||
--color-accent: #dc8a78;
|
||||
--color-accent-foreground: #eff1f5;
|
||||
--color-destructive: #d20f39;
|
||||
--color-destructive-foreground: #eff1f5;
|
||||
--color-border: #bcc0cc;
|
||||
--color-input: #bcc0cc;
|
||||
--color-ring: #1e66f5;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Catppuccin Macchiato - we'll use the same colors for dark mode */
|
||||
--color-background: #24273a;
|
||||
--color-foreground: #cad3f5;
|
||||
--color-card: #1e2030;
|
||||
--color-card-foreground: #cad3f5;
|
||||
--color-popover: #1e2030;
|
||||
--color-popover-foreground: #cad3f5;
|
||||
--color-primary: #8aadf4;
|
||||
--color-primary-foreground: #24273a;
|
||||
--color-secondary: #363a4f;
|
||||
--color-secondary-foreground: #cad3f5;
|
||||
--color-muted: #363a4f;
|
||||
--color-muted-foreground: #a5adcb;
|
||||
--color-accent: #f4dbd6;
|
||||
--color-accent-foreground: #24273a;
|
||||
--color-destructive: #ed8796;
|
||||
--color-destructive-foreground: #24273a;
|
||||
--color-border: #363a4f;
|
||||
--color-input: #363a4f;
|
||||
--color-ring: #8aadf4;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply box-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-background {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
@utility text-foreground {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Add other utility classes for colors */
|
||||
@utility bg-card {
|
||||
background-color: var(--color-card);
|
||||
}
|
||||
|
||||
@utility text-card-foreground {
|
||||
color: var(--color-card-foreground);
|
||||
}
|
||||
|
||||
@utility bg-primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@utility text-primary-foreground {
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
/* ... add other utility classes as needed ... */
|
||||
/* :root { */
|
||||
/* /* gruvbox light theme */ */
|
||||
/* --color-background: hsl(32 92% 87%); /* bg0 */ */
|
||||
|
|
@ -64,85 +147,3 @@
|
|||
/* --color-input: hsl(24 10% 51%); /* fg4 */ */
|
||||
/* --color-ring: hsl(6 93% 59%); /* red */ */
|
||||
/* } */
|
||||
:root {
|
||||
/* Catppuccin Macchiato */
|
||||
--color-background: #24273a;
|
||||
--color-foreground: #cad3f5;
|
||||
--color-card: #1e2030;
|
||||
--color-card-foreground: #cad3f5;
|
||||
--color-popover: #1e2030;
|
||||
--color-popover-foreground: #cad3f5;
|
||||
--color-primary: #8aadf4;
|
||||
--color-primary-foreground: #24273a;
|
||||
--color-secondary: #363a4f;
|
||||
--color-secondary-foreground: #cad3f5;
|
||||
--color-muted: #363a4f;
|
||||
--color-muted-foreground: #a5adcb;
|
||||
--color-accent: #f4dbd6;
|
||||
--color-accent-foreground: #24273a;
|
||||
--color-destructive: #ed8796;
|
||||
--color-destructive-foreground: #24273a;
|
||||
--color-border: #363a4f;
|
||||
--color-input: #363a4f;
|
||||
--color-ring: #8aadf4;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Catppuccin Macchiato - we'll use the same colors for dark mode */
|
||||
--color-background: #24273a;
|
||||
--color-foreground: #cad3f5;
|
||||
--color-card: #1e2030;
|
||||
--color-card-foreground: #cad3f5;
|
||||
--color-popover: #1e2030;
|
||||
--color-popover-foreground: #cad3f5;
|
||||
--color-primary: #8aadf4;
|
||||
--color-primary-foreground: #24273a;
|
||||
--color-secondary: #363a4f;
|
||||
--color-secondary-foreground: #cad3f5;
|
||||
--color-muted: #363a4f;
|
||||
--color-muted-foreground: #a5adcb;
|
||||
--color-accent: #f4dbd6;
|
||||
--color-accent-foreground: #24273a;
|
||||
--color-destructive: #ed8796;
|
||||
--color-destructive-foreground: #24273a;
|
||||
--color-border: #363a4f;
|
||||
--color-input: #363a4f;
|
||||
--color-ring: #8aadf4;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply box-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-background {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
@utility text-foreground {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Add other utility classes for colors */
|
||||
@utility bg-card {
|
||||
background-color: var(--color-card);
|
||||
}
|
||||
|
||||
@utility text-card-foreground {
|
||||
color: var(--color-card-foreground);
|
||||
}
|
||||
|
||||
@utility bg-primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@utility text-primary-foreground {
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
/* ... add other utility classes as needed ... */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
search: string
|
||||
town: string
|
||||
|
|
@ -28,6 +28,18 @@ const emit = defineEmits<{
|
|||
'update:town': [value: string]
|
||||
}>()
|
||||
|
||||
const searchValue = ref('')
|
||||
|
||||
// Watch for changes in the search value
|
||||
watch(searchValue, (newValue) => {
|
||||
emit('update:search', newValue)
|
||||
})
|
||||
|
||||
// Watch for prop changes to update local value
|
||||
watch(() => props.search, (newValue) => {
|
||||
searchValue.value = newValue
|
||||
}, { immediate: true })
|
||||
|
||||
const categoryIcons = {
|
||||
restaurant: UtensilsCrossed,
|
||||
lodging: Bed,
|
||||
|
|
@ -77,27 +89,33 @@ const towns = computed(() => [
|
|||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input type="text" :value="search" @input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
class="pl-10" :placeholder="t('directory.search')" />
|
||||
<Input
|
||||
v-model="searchValue"
|
||||
type="text"
|
||||
class="pl-10 w-full"
|
||||
:placeholder="t('directory.search')"
|
||||
inputmode="text"
|
||||
enterkeyhint="search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-1 sm:gap-2">
|
||||
<!-- Category Filter -->
|
||||
<div class="flex gap-1 sm:gap-2 overflow-x-auto pb-1 sm:pb-2 md:pb-0">
|
||||
<Button v-for="cat in categories" :key="cat.id" @click="emit('update:category', cat.id)"
|
||||
:variant="category === cat.id ? 'default' : 'secondary'" size="sm" class="rounded-full whitespace-nowrap">
|
||||
:variant="props.category === cat.id ? 'default' : 'secondary'" size="sm" class="rounded-full whitespace-nowrap">
|
||||
<!-- Show only icon on mobile for non-'all' categories -->
|
||||
<template v-if="cat.id !== 'all'">
|
||||
<component :is="cat.icon"
|
||||
class="h-4 w-4 md:mr-2"
|
||||
:class="[
|
||||
category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors],
|
||||
props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors],
|
||||
'md:hidden'
|
||||
]"
|
||||
/>
|
||||
<component :is="cat.icon"
|
||||
class="h-4 w-4 mr-2 hidden md:inline-block"
|
||||
:class="category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors]"
|
||||
:class="props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors]"
|
||||
/>
|
||||
<span class="hidden md:inline">{{ cat.label }}</span>
|
||||
</template>
|
||||
|
|
@ -111,7 +129,7 @@ const towns = computed(() => [
|
|||
<!-- Town Filter -->
|
||||
<div class="flex gap-1 sm:gap-2 overflow-x-auto pb-1 sm:pb-2 md:pb-0">
|
||||
<Button v-for="to in towns" :key="to.id" @click="emit('update:town', to.id)"
|
||||
:variant="town === to.id ? 'default' : 'secondary'" size="sm" class="rounded-full whitespace-nowrap">
|
||||
:variant="props.town === to.id ? 'default' : 'secondary'" size="sm" class="rounded-full whitespace-nowrap">
|
||||
{{ to.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,14 +53,14 @@ const filteredItems = computed(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-2 sm:py-8">
|
||||
<div>
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 sm:mb-8 space-y-4 md:space-y-0 md:flex md:items-center md:justify-between">
|
||||
<div class="space-y-2 md:space-y-0 md:flex md:items-center md:justify-between">
|
||||
<DirectoryFilter v-model:category="selectedCategory" v-model:search="searchQuery" v-model:town="selectedTown" />
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
||||
<DirectoryCard v-for="item in filteredItems" :key="item.id" :item="item" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Menu, X } from 'lucide-vue-next'
|
||||
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import { Menu, X, Sun, Moon, Zap } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const navigation = computed(() => [
|
||||
|
|
@ -17,62 +18,72 @@ const navigation = computed(() => [
|
|||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const toggleLocale = () => {
|
||||
// Toggle between 'en' and 'es'
|
||||
const newLocale = locale.value === 'en' ? 'es' : 'en'
|
||||
locale.value = newLocale
|
||||
// Store the preference
|
||||
localStorage.setItem('locale', newLocale)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-card border-b border-border">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo and Desktop Navigation -->
|
||||
<div class="flex">
|
||||
<div class="flex flex-shrink-0 items-center">
|
||||
<!-- Replace with your logo -->
|
||||
<router-link to="/" class="text-xl font-bold text-card-foreground">
|
||||
⚡️ {{ t('nav.title') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<nav class="w-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo and Desktop Navigation -->
|
||||
<div class="flex items-center gap-6">
|
||||
<router-link to="/" class="flex ml-4 items-center gap-2">
|
||||
<Zap class="h-6 w-6 text-orange-600 dark:text-orange-400" />
|
||||
<span class=" font-semibold">{{ t('nav.title') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
|
||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium" :class="[
|
||||
$route.path === item.href
|
||||
? 'border-b-2 border-primary text-card-foreground'
|
||||
: 'text-muted-foreground hover:border-b-2 hover:border-muted hover:text-card-foreground'
|
||||
]">
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex gap-4">
|
||||
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors" :class="{
|
||||
'text-foreground': $route.path === item.href
|
||||
}">
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<!-- Theme Toggle and Language -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
||||
<Moon v-else class="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="toggleLocale">
|
||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||
</Button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="flex items-center sm:hidden">
|
||||
<button type="button"
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500"
|
||||
@click="toggleMenu">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<Menu v-if="!isOpen" class="block h-6 w-6" aria-hidden="true" />
|
||||
<X v-else class="block h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="md:hidden" @click="toggleMenu">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<Menu v-if="!isOpen" class="h-5 w-5" />
|
||||
<X v-else class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div :class="[isOpen ? 'block' : 'hidden']" class="sm:hidden">
|
||||
<div class="space-y-1 pb-3 pt-2">
|
||||
<div v-show="isOpen"
|
||||
class="absolute left-0 right-0 top-14 z-50 border-b bg-background md:hidden
|
||||
[@supports(backdrop-filter:blur(24px))]:bg-background/95 [@supports(backdrop-filter:blur(24px))]:backdrop-blur-sm">
|
||||
<div class="container space-y-1 py-3">
|
||||
<router-link v-for="item in navigation" :key="item.name" :to="item.href"
|
||||
class="block border-l-4 py-2 pl-3 pr-4 text-base font-medium" :class="[
|
||||
$route.path === item.href
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-700'
|
||||
]" @click="isOpen = false">
|
||||
class="block px-3 py-2 text-base font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
:class="{
|
||||
'text-foreground': $route.path === item.href
|
||||
}" @click="isOpen = false">
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
|
|||
53
src/components/theme-provider/index.ts
Normal file
53
src/components/theme-provider/index.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
const useTheme = () => {
|
||||
const theme = ref<Theme>('dark')
|
||||
const systemTheme = ref<'dark' | 'light'>('light')
|
||||
|
||||
const updateSystemTheme = () => {
|
||||
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const currentTheme = computed(() => {
|
||||
return theme.value === 'system' ? systemTheme.value : theme.value
|
||||
})
|
||||
|
||||
const applyTheme = () => {
|
||||
if (currentTheme.value === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem('ui-theme')
|
||||
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
|
||||
theme.value = stored as Theme
|
||||
}
|
||||
|
||||
updateSystemTheme()
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
watch(currentTheme, () => {
|
||||
applyTheme()
|
||||
})
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('ui-theme', newTheme)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
systemTheme,
|
||||
currentTheme
|
||||
}
|
||||
}
|
||||
|
||||
export { useTheme }
|
||||
15
src/main.ts
15
src/main.ts
|
|
@ -3,8 +3,23 @@ import App from './App.vue'
|
|||
import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import './assets/index.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// Simple periodic service worker updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const items = mockDirectoryItems
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="container mx-auto px-4 py-2 md:py-8">
|
||||
<div class="space-y-4 sm:space-y-8">
|
||||
<!-- Directory Header - Hidden on mobile -->
|
||||
<div class="hidden sm:block text-center space-y-4">
|
||||
|
|
|
|||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
|
|
@ -1 +1,2 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const animate = require("tailwindcss-animate")
|
|||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
darkMode: ['class'],
|
||||
safelist: ["dark"],
|
||||
prefix: "",
|
||||
|
||||
|
|
@ -23,39 +23,39 @@ module.exports = {
|
|||
},
|
||||
extend: {
|
||||
colors: {
|
||||
background: 'var(--color-background)',
|
||||
foreground: 'var(--color-foreground)',
|
||||
card: {
|
||||
DEFAULT: 'var(--color-card)',
|
||||
foreground: 'var(--color-card-foreground)',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'var(--color-popover)',
|
||||
foreground: 'var(--color-popover-foreground)',
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'var(--color-primary)',
|
||||
foreground: 'var(--color-primary-foreground)',
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'var(--color-secondary)',
|
||||
foreground: 'var(--color-secondary-foreground)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'var(--color-muted)',
|
||||
foreground: 'var(--color-muted-foreground)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'var(--color-accent)',
|
||||
foreground: 'var(--color-accent-foreground)',
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'var(--color-destructive)',
|
||||
foreground: 'var(--color-destructive-foreground)',
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
border: 'var(--color-border)',
|
||||
input: 'var(--color-input)',
|
||||
ring: 'var(--color-ring)',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
|
|
|
|||
|
|
@ -2,10 +2,57 @@ import { fileURLToPath, URL } from 'node:url'
|
|||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true
|
||||
},
|
||||
workbox: {
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg}'
|
||||
]
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||
manifest: {
|
||||
name: 'Atitlán Directory',
|
||||
short_name: 'Atitlán',
|
||||
description: 'Find Bitcoin Lightning acceptors around Lake Atitlán',
|
||||
theme_color: '#ffffff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue