initial version
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
55
README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Augsburg Mosaic
|
||||
|
||||
This project blossomed from a genuine fondness for the city of Augsburg, aiming to unfold its hidden gems and notable locales to both visitors and residents alike.
|
||||
|
||||
![Project Screenshot](screenshot.png)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Augsburg Mosaic serves as a digital guide, offering a curated list of places categorized as Attractions, Food spots, Cultural hubs, Nature retreats, and Others. Through an interactive map and a user-friendly list, one can explore, filter, and learn more about each location.
|
||||
|
||||
Utilizing technologies such as Vue.js, Nuxt.js, Leaflet.js for map rendering, and Pinia for state management, this project embodies a seamless blend of design and functionality, all while being a purely static site optimized for SEO. This also means, there are no cookies or any other data collection mechanisms involved, especially not concerning personal data.
|
||||
|
||||
This endeavor is purely a hobby, unfunded yet rich in spirit. The icons embellishing this site are courtesy of FontAwesome, while the images are captured through the lens of [WieErWill](https://wieerwill.de).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Ensure you have [Node.js](https://nodejs.org/) installed on your machine.
|
||||
- This project uses [pnpm](https://pnpm.io/) as the package manager. Install it globally using `npm install -g pnpm`.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork this repository to your GitHub account.
|
||||
2. Clone your forked repository to your local machine: `git clone https://github.com/wieerwill/augsburg-mosaic.git`
|
||||
3. Navigate to the project directory: `cd augsburg-mosaic`
|
||||
4. Install the project dependencies: `pnpm install`
|
||||
5. Start the development server: `pnpm dev`
|
||||
|
||||
The project should now be running on [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
### Building
|
||||
|
||||
To build the static version of the project, run:
|
||||
|
||||
```bash
|
||||
pnpm generate
|
||||
```
|
||||
|
||||
The static files will be generated in the dist directory, ready to be deployed.
|
||||
Contributing
|
||||
|
||||
Feel free to explore, report issues, or open pull requests. If you find this project useful, please consider giving it a star! ⭐
|
||||
|
||||
### License
|
||||
|
||||
This project is open-source and available under the MIT License.
|
||||
|
||||
### Acknowledgements
|
||||
|
||||
Icons provided by FontAwesome.
|
||||
|
||||
Map rendering powered by Leaflet.js.
|
||||
|
||||
Enjoy traversing through the mosaic of Augsburg!
|
13
app.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
<style>
|
||||
html {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
195
assets/locations.json
Normal file
@ -0,0 +1,195 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Zoo",
|
||||
"description": "Home to over 1,600 animals of 300 species, Augsburg Zoo is a fun destination for families.",
|
||||
"category": "attraction",
|
||||
"coordinates": [
|
||||
48.34879,
|
||||
10.91588
|
||||
],
|
||||
"address": "Brehmplatz 1, 86161 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "Great for a day out with kids."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Town Hall",
|
||||
"description": "A Renaissance architectural masterpiece with a beautiful interior including the Golden Hall.",
|
||||
"category": "cultural",
|
||||
"coordinates": [
|
||||
48.3688702145732,
|
||||
10.89869720434145
|
||||
],
|
||||
"address": "Rathauspl. 1, 86150 Augsburg",
|
||||
"rating": 5,
|
||||
"image": null,
|
||||
"notes": "The Golden Hall is a must-see."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Fuggerei",
|
||||
"description": "The world's oldest social housing complex still in use.",
|
||||
"category": "cultural",
|
||||
"coordinates": [
|
||||
48.36987006188504,
|
||||
10.903964160650018
|
||||
],
|
||||
"address": "Jakoberstraße 26, 86152 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "A quiet, peaceful place with a rich history."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Perlachturm",
|
||||
"description": "A historic tower offering panoramic views of Augsburg. (currently closed)",
|
||||
"category": "attraction",
|
||||
"coordinates": [
|
||||
48.36920628764096,
|
||||
10.898450278952886
|
||||
],
|
||||
"address": "Rathauspl. 1, 86150 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "Wear comfortable shoes for the climb."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Puppenkiste",
|
||||
"description": "A famous marionette theater with a museum showcasing historical puppets.",
|
||||
"category": "cultural",
|
||||
"coordinates": [
|
||||
48.3603972116233,
|
||||
10.903206140281654
|
||||
],
|
||||
"address": "Spitalgasse 15, 86150 Augsburg",
|
||||
"rating": 5,
|
||||
"image": null,
|
||||
"notes": "A magical experience for all ages."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Botanical Garden",
|
||||
"description": "A serene botanical garden with themed areas and a butterfly house.",
|
||||
"category": "nature",
|
||||
"coordinates": [
|
||||
48.34957863531708,
|
||||
10.915033207995533
|
||||
],
|
||||
"address": "Dr.-Ziegenspeck-Weg 10, 86161 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "The butterfly house is a highlight."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Maximilianmuseum",
|
||||
"description": "A museum housing a variety of historical artifacts and artworks.",
|
||||
"category": "cultural",
|
||||
"coordinates": [
|
||||
48.36794304122964,
|
||||
10.896419926758991
|
||||
],
|
||||
"address": "Fuggerplatz 1, 86150 Augsburg",
|
||||
"rating": 3,
|
||||
"image": null,
|
||||
"notes": "Rich in local history."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Eiskanal",
|
||||
"description": "An artificial white-water river offering canoeing and kayaking opportunities.",
|
||||
"category": "other",
|
||||
"coordinates": [
|
||||
48.34801468191772,
|
||||
10.936438664823985
|
||||
],
|
||||
"address": "Eiskanalstraße 30, 86161 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "A thrilling experience for adventure seekers."
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Schaezlerpalais",
|
||||
"description": "A baroque palace with stunning interiors and an art gallery.",
|
||||
"category": "cultural",
|
||||
"coordinates": [
|
||||
48.36515672496401,
|
||||
10.899364991520201
|
||||
],
|
||||
"address": "Maximilianstraße 46, 86150 Augsburg",
|
||||
"rating": 4,
|
||||
"image": null,
|
||||
"notes": "The Rococo ballroom is a masterpiece."
|
||||
},
|
||||
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Masericó",
|
||||
"category": "food",
|
||||
"coordinates": [48.37465547077278, 10.895556446113813],
|
||||
"address": "Frauentorstraße 25, 86152 Augsburg",
|
||||
"rating": "",
|
||||
"description": "Finest Pizza selection, always freshly baken and great combinations possible",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Trattoria Pizzeria Crudo",
|
||||
"category": "food",
|
||||
"coordinates": [48.379074019160875, 10.891983555594491],
|
||||
"address": "Am Pfannenstiel 20, 86153 Augsburg",
|
||||
"rating": "",
|
||||
"description": "Italian, Pizza, Seafood, Mediterranean",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Shushu Falafel",
|
||||
"category": "food",
|
||||
"coordinates": [48.37083274095064, 10.897619465655083],
|
||||
"address": "Karlstraße 2, 86150 Augsburg",
|
||||
"rating": "",
|
||||
"description": "Syrian, Lebanese, Middle Eastern",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Riegele WirtsHaus",
|
||||
"category": "food",
|
||||
"coordinates": [48.36772460100637, 10.88493485006803],
|
||||
"address": "Frölichstraße 26, 86150 Augsburg",
|
||||
"rating": "",
|
||||
"description": "traditional and modern bavrian, home bew beer and always up to new ideas",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Got Asia",
|
||||
"category": "food",
|
||||
"coordinates": [48.37044353815951, 10.89805742563597],
|
||||
"address": "Leonhardsberg 2, 86150 Augsburg",
|
||||
"rating": "",
|
||||
"description": "Asian Cuisine, Sushi and good udon noodles",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "Taverna Ikaros",
|
||||
"category": "food",
|
||||
"coordinates": [48.36173471336735, 10.871633911417918],
|
||||
"address": "Sigmundstraße 3, 86157 Augsburg",
|
||||
"rating": "",
|
||||
"description": "best greek i found so far in Augsburg, friendly Service and i can suggest the Zeus plate ;)",
|
||||
"image": "",
|
||||
"notes": ""
|
||||
}
|
||||
]
|
134
components/Filter.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="filter-container">
|
||||
<div class="categories">
|
||||
<button v-for="category in categories" :key="category.value"
|
||||
:class="{ 'selected': selectedCategories.includes(category.value) }" @click="toggleCategory(category.value)"
|
||||
:title="category.label">
|
||||
<img :src="`/icons/${category.value}.svg`" :alt="category.label">
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-container">
|
||||
<img src="/icons/search.svg" alt="Search Icon" class="search-icon">
|
||||
<input type="text" v-model="searchText" @input="updateFilter" placeholder="Search by title or address"
|
||||
class="search-input">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
locations: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
categories: [
|
||||
{ value: 'attraction', label: 'Attraction' },
|
||||
{ value: 'food', label: 'Food' },
|
||||
{ value: 'cultural', label: 'Cultural' },
|
||||
{ value: 'nature', label: 'Nature' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
],
|
||||
selectedCategories: ['attraction', 'food', 'cultural', 'nature', 'other'],
|
||||
searchText: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredLocations() {
|
||||
const lowerCaseSearchText = this.searchText.toLowerCase();
|
||||
const filtered = this.locations.filter(location =>
|
||||
this.selectedCategories.includes(location.category) &&
|
||||
(location.title.toLowerCase().includes(lowerCaseSearchText) ||
|
||||
location.address.toLowerCase().includes(lowerCaseSearchText))
|
||||
);
|
||||
return filtered.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleCategory(category) {
|
||||
const index = this.selectedCategories.indexOf(category);
|
||||
if (index > -1) {
|
||||
this.selectedCategories.splice(index, 1);
|
||||
} else {
|
||||
this.selectedCategories.push(category);
|
||||
}
|
||||
this.updateFilter();
|
||||
},
|
||||
updateFilter() {
|
||||
this.$emit('update-filter', this.filteredLocations);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 2px solid #454545;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.categories button {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
margin: 0 5px;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.categories button img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
padding: 3px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.categories button.selected {
|
||||
background-color: #2dff1ac6;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
width: 90%;
|
||||
margin: 0 auto 3px auto;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
padding-left: 35px;
|
||||
/* Adjusted padding to account for the icon */
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
18
components/Footer.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<footer>
|
||||
<a href="/about">Learn more about <em><Augsburg Mosaik/></em></a>
|
||||
</footer>
|
||||
</template>
|
||||
<style scoped>
|
||||
footer {
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
74
components/Header.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<h1 class="title">Augsburg Mosaic</h1>
|
||||
<p class="description">
|
||||
Discover Augsburg through my curated list of notable locations, events, and eateries. Whether you're a visitor
|
||||
exploring the city for the first time or a local seeking new experiences, this guide aims to enrich your journey
|
||||
through Augsburg.
|
||||
</p>
|
||||
<p class="hint" v-if="isMobile">
|
||||
For an enhanced experience, view this website on a larger screen.
|
||||
</p>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useMainStore } from '~/store/pinia';
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const store = useMainStore();
|
||||
const isMobile = ref(store.isMobile);
|
||||
|
||||
const updateIsMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
store.setIsMobile(isMobile.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
updateIsMobile();
|
||||
window.addEventListener('resize', updateIsMobile);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
updateIsMobile
|
||||
};
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (process.client) {
|
||||
window.removeEventListener('resize', this.updateIsMobile);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background-color: #f0f0f0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #555;
|
||||
line-height: 1.3;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
179
components/LocationList.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="location-list">
|
||||
<div v-for="location in locations" :key="location.id" class="location-card"
|
||||
:class="{ highlighted: selectedLocation && selectedLocation.id === location.id }" :data-id="location.id"
|
||||
ref="locationCards" @click="handleLocationClick(location)">
|
||||
<div class="card-header">
|
||||
<img v-if="location.image" :src="location.image" alt="location image" class="location-image" />
|
||||
<div class="location-info">
|
||||
<h3 class="location-title">{{ location.title }}</h3>
|
||||
<p class="location-category">{{ location.category }}</p>
|
||||
<!--<div class="location-rating">
|
||||
<span v-for="n in 5" :key="n" class="star" :class="{ filled: n <= location.rating }">★</span>
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
<p class="location-description">{{ location.description }}</p>
|
||||
<p class="location-address">{{ location.address }}</p>
|
||||
<!--<div class="location-notes">
|
||||
<p><strong>Notes:</strong> {{ location.notes }}</p>
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { nextTick, defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
locations: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedLocation: {
|
||||
require: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locationCards: []
|
||||
};
|
||||
},
|
||||
updated() {
|
||||
if (this.selectedLocation) {
|
||||
const highlightedCard = this.locationCards.find(card => card.dataset.id === this.selectedLocation.id.toString());
|
||||
if (highlightedCard) {
|
||||
highlightedCard.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedLocation: {
|
||||
immediate: true,
|
||||
handler(newValue) {
|
||||
if (newValue) {
|
||||
this.scrollToLocation(newValue.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ['location-click'],
|
||||
methods: {
|
||||
handleLocationClick(location) {
|
||||
this.$emit('location-click', location);
|
||||
},
|
||||
scrollToLocation(id) {
|
||||
nextTick(() => {
|
||||
const highlightedCard = Array.from(this.$refs.locationCards).find(card => card.dataset.id === id.toString());
|
||||
if (highlightedCard) {
|
||||
highlightedCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.location-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
/* Makes the location list scrollable */
|
||||
padding: 20px;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.location-card {
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3e3c3c81;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-card:hover {
|
||||
transform: scaleY(1.05) scaleX(1.01);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.location-image {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.location-title {
|
||||
font-size: 1.5em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.location-category {
|
||||
font-size: 1em;
|
||||
margin: 5px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.location-rating .star {
|
||||
font-size: 1.2em;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.location-rating .star.filled {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.location-description,
|
||||
.location-address {
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.location-notes {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Media query for larger screens */
|
||||
@media (min-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.location-image {
|
||||
margin-bottom: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.location-info,
|
||||
.location-description,
|
||||
.location-address,
|
||||
.location-notes {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: rgba(157, 221, 221, 0.551);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
</style>
|
31
components/Map.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="map-component">
|
||||
<LMap ref="map" :zoom="zoom" :center="center" :max-bounds="maxBounds">
|
||||
<LTileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution="&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors"
|
||||
layer-type="base" name="OpenStreetMap" />
|
||||
<LMarker v-for="location in locations" :key="location.id" :lat-lng="location.coordinates"
|
||||
@click="handleMarkerClick(location)" />
|
||||
</LMap>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps(['center', 'zoom', 'maxBounds', 'locations', 'selectedLocation']);
|
||||
|
||||
const emit = defineEmits(['marker-click']);
|
||||
|
||||
const handleMarkerClick = (location) => {
|
||||
emit('marker-click', location);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-component {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 1px solid #434343
|
||||
}
|
||||
</style>
|
32
mixins/mobileMixin.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { useMainStore } from '~/store/pinia';
|
||||
|
||||
const mobileMixin = {
|
||||
setup() {
|
||||
const store = useMainStore();
|
||||
const isMobile = ref(store.isMobile);
|
||||
|
||||
const updateIsMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
store.setIsMobile(isMobile.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
updateIsMobile();
|
||||
window.addEventListener('resize', updateIsMobile);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
updateIsMobile
|
||||
};
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (process.client) {
|
||||
window.removeEventListener('resize', this.updateIsMobile);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default mobileMixin
|
14
nuxt.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
target: 'static',
|
||||
head: {
|
||||
title: 'Augsburg Mosaic',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ hid: 'description', name: 'description', content: 'Interactive map of Augsburg with recommended locations to visit.' }
|
||||
]
|
||||
},
|
||||
modules: ['nuxt3-leaflet', '@pinia/nuxt'],
|
||||
devtools: { enabled: true }
|
||||
})
|
3
old_server/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
"nuxt": "^3.8.0",
|
||||
"vue": "^3.3.7",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"nuxt3-leaflet": "^1.0.12",
|
||||
"pinia": "^2.1.7"
|
||||
}
|
||||
}
|
69
pages/about.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="about-container">
|
||||
<h1 class="about-title">Augsburg Mosaic</h1>
|
||||
<p class="about-description">
|
||||
Welcome to Augsburg Mosaic, a delightful endeavor crafted by the creative mind <a href="https://wieerwill.de" class="external-link">WieErWill</a>.
|
||||
This project blossomed from a genuine fondness for the city of Augsburg, aiming to unfold its hidden gems and notable locales to both visitors and residents alike.
|
||||
</p>
|
||||
<p class="about-detail">
|
||||
Augsburg Mosaic serves as a digital guide, offering a curated list of places categorized as Attractions, Food spots, Cultural hubs, Nature retreats, and Others.
|
||||
Through an interactive map and a user-friendly list, one can explore, filter, and learn more about each location.
|
||||
Utilizing technologies such as Vue.js, Nuxt.js, Leaflet.js for map rendering, and Pinia for state management, this project embodies a seamless blend of design and functionality,
|
||||
all while being a purely static site optimized for SEO. This also means, i don't use cookies or any other data, especially not yours in any way.
|
||||
</p>
|
||||
<p class="about-note">
|
||||
This endeavor is purely a hobby, unfunded yet rich in spirit. Feel free to traverse through the mosaic of Augsburg,
|
||||
all I hope is for it to bring joy and not to cause harm. The icons embellishing this site are courtesy of FontAwesome,
|
||||
while the images are captured through my lens.
|
||||
</p>
|
||||
<a href="/" class="back-link"><strong>Back to Map</strong></a>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.about-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin: auto;
|
||||
max-width: 800px;
|
||||
background-color: #f7f4f4;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 16px 16px rgba(0, 0, 0, 0.2);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.about-description,
|
||||
.about-detail,
|
||||
.about-note {
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.external-link,
|
||||
.back-link {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.external-link:hover,
|
||||
.back-link:hover {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #3498db;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
160
pages/index.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Header />
|
||||
<div class="container">
|
||||
<div class="sidebar" :class="{ 'is-mobile': isMobile }">
|
||||
<Filter :locations="locations" @update-filter="updateFilteredLocations" />
|
||||
<div class="location-list-container">
|
||||
<LocationList :locations="filteredLocations" :selectedLocation="selectedLocation"
|
||||
@location-click="handleLocationClick" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-container" v-if="!isMobile">
|
||||
<Map :center="mapCenter" :zoom="mapZoom" :maxBounds="maxBounds" :locations="filteredLocations"
|
||||
@marker-click="handleMarkerClick" :selectedLocation="selectedLocation" ref="map" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import locationsData from '~/assets/locations.json';
|
||||
import Header from '~/components/Header.vue';
|
||||
import Footer from '~/components/Footer.vue';
|
||||
import Filter from '~/components/Filter.vue';
|
||||
import LocationList from '~/components/LocationList.vue';
|
||||
import Map from '~/components/Map.vue';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isMobile: false,
|
||||
maxBounds: [
|
||||
[48.2, 10.6], // Southwest coordinates
|
||||
[48.6, 11.2] // Northeast coordinates
|
||||
],
|
||||
mapCenter: [48.3689, 10.8979],
|
||||
mapZoom: 12,
|
||||
selectedLocation: null,
|
||||
filteredLocations: [],
|
||||
locations: locationsData
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Header,
|
||||
Footer,
|
||||
Filter,
|
||||
LocationList,
|
||||
Map
|
||||
},
|
||||
created() {
|
||||
this.filteredLocations = [...this.locations].sort((a, b) => a.title.localeCompare(b.title))
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(this.updateHeaderFooterHeight);
|
||||
window.addEventListener('resize', this.updateHeaderFooterHeight);
|
||||
|
||||
this.$nextTick(this.updateLocationListHeight);
|
||||
window.addEventListener('resize', this.updateLocationListHeight);
|
||||
|
||||
this.handleResize();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.updateHeaderFooterHeight);
|
||||
window.removeEventListener('resize', this.updateLocationListHeight);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
methods: {
|
||||
updateFilteredLocations(filteredLocations) {
|
||||
this.filteredLocations = filteredLocations;
|
||||
},
|
||||
handleLocationClick(location) {
|
||||
this.selectedLocation = location;
|
||||
this.mapCenter = location.coordinates;
|
||||
this.mapZoom = 16;
|
||||
},
|
||||
handleMarkerClick(location) {
|
||||
this.selectedLocation = location;
|
||||
this.mapCenter = location.coordinates;
|
||||
},
|
||||
handleResize() {
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
},
|
||||
updateHeaderFooterHeight() {
|
||||
const headerHeight = document.querySelector('header').offsetHeight;
|
||||
const footerHeight = document.querySelector('footer').offsetHeight;
|
||||
const totalHeight = headerHeight + footerHeight;
|
||||
document.documentElement.style.setProperty('--header-footer-height', `${totalHeight}px`);
|
||||
},
|
||||
updateLocationListHeight() {
|
||||
const filterHeight = document.querySelector('.filter-container').offsetHeight;
|
||||
const containerHeight = document.querySelector('.container').clientHeight;
|
||||
const newHeight = containerHeight - filterHeight - 45
|
||||
const locationListElement = document.querySelector('.location-list');
|
||||
locationListElement.style.height = `${newHeight}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background-color: #e0f7fa;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - var(--header-footer-height));
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
background-color: #f9f9f9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #434343
|
||||
}
|
||||
|
||||
.location-list-container {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
transition: flex 0.3s;
|
||||
min-width: 10rem;
|
||||
min-height: 10rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
.is-mobile .map-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
/* Mosaic style background for body */
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
background-image:
|
||||
linear-gradient(45deg, #f06, #f06 25%, transparent 25%, transparent 75%, #f06 75%, #f06),
|
||||
linear-gradient(45deg, #f06, #f06 25%, transparent 25%, transparent 75%, #f06 75%, #f06);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 10px 10px;
|
||||
}
|
||||
</style>
|
6015
pnpm-lock.yaml
Normal file
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
1
public/icons/attraction.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M0 160v96C0 379.7 100.3 480 224 480s224-100.3 224-224V160H320v96c0 53-43 96-96 96s-96-43-96-96V160H0zm0-32H128V64c0-17.7-14.3-32-32-32H32C14.3 32 0 46.3 0 64v64zm320 0H448V64c0-17.7-14.3-32-32-32H352c-17.7 0-32 14.3-32 32v64z"/></svg>
|
After Width: | Height: | Size: 487 B |
1
public/icons/cultural.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M240.1 4.2c9.8-5.6 21.9-5.6 31.8 0l171.8 98.1L448 104l0 .9 47.9 27.4c12.6 7.2 18.8 22 15.1 36s-16.4 23.8-30.9 23.8H32c-14.5 0-27.2-9.8-30.9-23.8s2.5-28.8 15.1-36L64 104.9V104l4.4-1.6L240.1 4.2zM64 224h64V416h40V224h64V416h48V224h64V416h40V224h64V420.3c.6 .3 1.2 .7 1.8 1.1l48 32c11.7 7.8 17 22.4 12.9 35.9S494.1 512 480 512H32c-14.1 0-26.5-9.2-30.6-22.7s1.1-28.1 12.9-35.9l48-32c.6-.4 1.2-.7 1.8-1.1V224z"/></svg>
|
After Width: | Height: | Size: 666 B |
1
public/icons/food.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M416 0C400 0 288 32 288 176V288c0 35.3 28.7 64 64 64h32V480c0 17.7 14.3 32 32 32s32-14.3 32-32V352 240 32c0-17.7-14.3-32-32-32zM64 16C64 7.8 57.9 1 49.7 .1S34.2 4.6 32.4 12.5L2.1 148.8C.7 155.1 0 161.5 0 167.9c0 45.9 35.1 83.6 80 87.7V480c0 17.7 14.3 32 32 32s32-14.3 32-32V255.6c44.9-4.1 80-41.8 80-87.7c0-6.4-.7-12.8-2.1-19.1L191.6 12.5c-1.8-8-9.3-13.3-17.4-12.4S160 7.8 160 16V150.2c0 5.4-4.4 9.8-9.8 9.8c-5.1 0-9.3-3.9-9.8-9L127.9 14.6C127.2 6.3 120.3 0 112 0s-15.2 6.3-15.9 14.6L83.7 151c-.5 5.1-4.7 9-9.8 9c-5.4 0-9.8-4.4-9.8-9.8V16zm48.3 152l-.3 0-.3 0 .3-.7 .3 .7z"/></svg>
|
After Width: | Height: | Size: 834 B |
1
public/icons/nature.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M210.6 5.9L62 169.4c-3.9 4.2-6 9.8-6 15.5C56 197.7 66.3 208 79.1 208H104L30.6 281.4c-4.2 4.2-6.6 10-6.6 16C24 309.9 34.1 320 46.6 320H80L5.4 409.5C1.9 413.7 0 419 0 424.5c0 13 10.5 23.5 23.5 23.5H192v32c0 17.7 14.3 32 32 32s32-14.3 32-32V448H424.5c13 0 23.5-10.5 23.5-23.5c0-5.5-1.9-10.8-5.4-15L368 320h33.4c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16L344 208h24.9c12.7 0 23.1-10.3 23.1-23.1c0-5.7-2.1-11.3-6-15.5L237.4 5.9C234 2.1 229.1 0 224 0s-10 2.1-13.4 5.9z"/></svg>
|
After Width: | Height: | Size: 728 B |
1
public/icons/other.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M181.5 197.1l12.9 6.4c5.9 3 12.4 4.5 19.1 4.5c23.5 0 42.6-19.1 42.6-42.6V144c0-35.3-28.7-64-64-64H128c-35.3 0-64 28.7-64 64v21.4c0 23.5 19.1 42.6 42.6 42.6c6.6 0 13.1-1.5 19.1-4.5l12.9-6.4 8.4-4.2L135.1 185c-4.5-3-7.1-8-7.1-13.3V168c0-13.3 10.7-24 24-24h16c13.3 0 24 10.7 24 24v3.7c0 5.3-2.7 10.3-7.1 13.3l-11.8 7.9 8.4 4.2zm-8.6 49.4L160 240l-12.9 6.4c-12.6 6.3-26.5 9.6-40.5 9.6c-3.6 0-7.1-.2-10.6-.6v.6c0 35.3 28.7 64 64 64h64c17.7 0 32 14.3 32 32s-14.3 32-32 32H384V336 320c0-23.7 12.9-44.4 32-55.4c9.4-5.4 20.3-8.6 32-8.6V240c0-26.5 21.5-48 48-48c8.8 0 16 7.2 16 16v32 16 48c0 8.8 7.2 16 16 16s16-7.2 16-16V204.3c0-48.2-30.8-91-76.6-106.3l-8.5-2.8c-8-2.7-12.6-11.1-10.4-19.3s10.3-13.2 18.6-11.6l19.9 4C576 86.1 640 164.2 640 254.9l0 1.1h0c0 123.7-100.3 224-224 224h-1.1H256h-.6C132 480 32 380 32 256.6V256 216.8c-10.1-14.6-16-32.3-16-51.4V144l0-1.4C6.7 139.3 0 130.5 0 120c0-13.3 10.7-24 24-24h2.8C44.8 58.2 83.3 32 128 32h64c44.7 0 83.2 26.2 101.2 64H296c13.3 0 24 10.7 24 24c0 10.5-6.7 19.3-16 22.6l0 1.4v21.4c0 1.4 0 2.8-.1 4.3c12-6.2 25.7-9.6 40.1-9.6h8c17.7 0 32 14.3 32 32s-14.3 32-32 32h-8c-13.3 0-24 10.7-24 24v8h56.4c-15.2 17-24.4 39.4-24.4 64H320c-42.3 0-78.2-27.4-91-65.3c-5.1 .9-10.3 1.3-15.6 1.3c-14.1 0-27.9-3.3-40.5-9.6zM96 128a16 16 0 1 1 0 32 16 16 0 1 1 0-32zm112 16a16 16 0 1 1 32 0 16 16 0 1 1 -32 0z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
public/icons/search.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>
|
After Width: | Height: | Size: 494 B |
BIN
screenshot.png
Normal file
After Width: | Height: | Size: 501 KiB |
21
store/pinia.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useMainStore = defineStore({
|
||||
id: 'main',
|
||||
state: () => ({
|
||||
isMobile: false,
|
||||
selectedLocation: null,
|
||||
mapCenter: [48.371, 10.898],
|
||||
mapZoom: 13,
|
||||
}),
|
||||
actions: {
|
||||
setIsMobile(value) {
|
||||
this.isMobile = value;
|
||||
},
|
||||
selectLocation(location) {
|
||||
this.selectedLocation = location;
|
||||
this.mapCenter = location.coordinates;
|
||||
this.mapZoom = 15;
|
||||
},
|
||||
}
|
||||
});
|
3
tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|