Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,529 changes: 5,529 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
},
"dependencies": {
"@mdi/font": "7.4.47",
"axios": "^1.14.0",
"core-js": "^3.37.1",
"pinia": "^3.0.4",
"roboto-fontface": "*",
"vue": "^3.4.31",
"vuetify": "^3.6.14"
Expand Down
85 changes: 85 additions & 0 deletions src/components/AddRocketDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<template>
<v-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" max-width="600px">
<v-card>
<v-card-title>
<span class="text-h5">Add New Rocket</span>
</v-card-title>

<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-text-field v-model="form.name" label="Rocket Name*" required></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea v-model="form.description" label="Description*" required></v-textarea>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="form.country" label="Country"></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="form.first_flight" type="date" label="First Flight"></v-text-field>
</v-col>
<v-col cols="12">
<v-text-field v-model.number="form.cost_per_launch" type="number" label="Cost Per Launch ($)"></v-text-field>
</v-col>
</v-row>
</v-container>
<small>*indicates required field</small>
</v-card-text>

<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="$emit('update:modelValue', false)">
Close
</v-btn>
<v-btn color="blue-darken-1" variant="text" :disabled="!isFormValid" @click="save">
Save
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup lang="ts">
import { reactive, computed } from 'vue'

defineProps<{
modelValue: boolean
}>()

const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'save', rocket: any): void
}>()

const form = reactive({
name: '',
description: '',
country: '',
first_flight: '',
cost_per_launch: 0
})

const isFormValid = computed(() => !!form.name && !!form.description)

function save() {
const newRocket = {
id: 'manual-' + Date.now().toString(),
name: form.name,
description: form.description,
country: form.country,
first_flight: form.first_flight,
cost_per_launch: form.cost_per_launch,
flickr_images: []
}
emit('save', newRocket)

// reset form quickly
form.name = ''
form.description = ''
form.country = ''
form.first_flight = ''
form.cost_per_launch = 0
}
</script>
42 changes: 42 additions & 0 deletions src/components/RocketCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<v-card class="d-flex flex-column h-100" hover @click="$router.push(`/rocket/${rocket.id}`)">
<v-img
class="align-end bg-grey-lighten-2"
height="250"
:src="rocket.flickr_images && rocket.flickr_images.length ? rocket.flickr_images[0] : 'https://via.placeholder.com/400x250?text=No+Image'"
cover
>
<v-card-title class="text-white bg-black bg-opacity-50" style="background: rgba(0,0,0,0.5)">
{{ rocket.name }}
</v-card-title>
</v-img>

<v-card-text class="flex-grow-1">
<div class="text-truncate-3 text-body-1">{{ rocket.description }}</div>
</v-card-text>

<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text">
View Details
</v-btn>
</v-card-actions>
</v-card>
</template>

<script setup lang="ts">
import type { Rocket } from '@/stores/rocketStore'

defineProps<{
rocket: Rocket
}>()
</script>

<style scoped>
.text-truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
78 changes: 75 additions & 3 deletions src/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,79 @@
<template>
<HelloWorld />
<v-container>
<v-row class="mb-4" align="center">
<v-col cols="12" sm="8">
<v-text-field
v-model="store.searchQuery"
label="Search Rockets"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
></v-text-field>
</v-col>
<v-col cols="12" sm="4" class="text-sm-right">
<v-btn color="primary" @click="dialog = true" prepend-icon="mdi-plus">
Add Rocket
</v-btn>
</v-col>
</v-row>

<!-- Error State -->
<v-alert
v-if="store.error"
type="error"
title="Error Loading Data"
:text="store.error"
class="mb-4"
>
<template v-slot:append>
<v-btn color="white" variant="text" @click="store.fetchRockets()">Retry</v-btn>
</template>
</v-alert>

<!-- Loading State -->
<v-row v-if="store.isLoading" justify="center" class="my-10">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</v-row>

<!-- Success State -->
<v-row v-else-if="!store.error">
<v-col
v-for="rocket in store.filteredRockets"
:key="rocket.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<RocketCard :rocket="rocket" />
</v-col>
<v-col v-if="store.filteredRockets.length === 0" cols="12" class="text-center text-grey my-10">
No rockets found matching your search.
</v-col>
</v-row>

<!-- Add Rocket Dialog -->
<AddRocketDialog v-model="dialog" @save="onAddRocket" />
</v-container>
</template>

<script lang="ts" setup>
//
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRocketStore, type Rocket } from '@/stores/rocketStore'
import RocketCard from '@/components/RocketCard.vue'
import AddRocketDialog from '@/components/AddRocketDialog.vue'

const store = useRocketStore()
const dialog = ref(false)

onMounted(() => {
store.fetchRockets()
})

function onAddRocket(newRocket: Rocket) {
store.addManualRocket(newRocket)
dialog.value = false
}
</script>
64 changes: 64 additions & 0 deletions src/pages/rocket/[id].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<v-container>
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="$router.push('/')" class="mb-4">
Back to Rockets
</v-btn>

<!-- Handle Loading if directly visiting the URL -->
<v-row v-if="store.isLoading" justify="center" class="my-10">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</v-row>

<!-- Content -->
<v-row v-else-if="rocket">
<v-col cols="12" md="6">
<v-carousel hide-delimiters height="400">
<v-carousel-item
v-for="(img, i) in (rocket.flickr_images?.length ? rocket.flickr_images : ['https://via.placeholder.com/800x400?text=No+Image'])"
:key="i"
:src="img"
cover
></v-carousel-item>
</v-carousel>
</v-col>
<v-col cols="12" md="6">
<h1 class="text-h3 mb-2">{{ rocket.name }}</h1>
<p class="text-body-1 text-grey-darken-1 mb-4">{{ rocket.country }} &bull; First Flight: {{ rocket.first_flight }}</p>

<p class="text-body-1 mb-6">{{ rocket.description }}</p>

<v-card variant="outlined" class="pa-4 bg-grey-lighten-4">
<div class="text-h6 text-primary">Cost per Launch</div>
<div class="text-h4 font-weight-bold">${{ (rocket.cost_per_launch || 0).toLocaleString() }}</div>
</v-card>
</v-col>
</v-row>

<!-- Not Found -->
<v-row v-else justify="center" class="my-10">
<v-col cols="12" class="text-center">
<h2 class="text-h5 text-grey">Rocket not found</h2>
</v-col>
</v-row>
</v-container>
</template>

<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRocketStore } from '@/stores/rocketStore'

const route = useRoute()
const store = useRocketStore()

const rocketId = computed(() => route.params.id as string)

const rocket = computed(() => {
return store.rockets.find(r => r.id === rocketId.value)
})

onMounted(() => {
// If navigating directly to detail layout, initiate a background fetch
store.fetchRockets()
})
</script>
2 changes: 2 additions & 0 deletions src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

// Plugins
import { createPinia } from 'pinia'
import vuetify from './vuetify'
import router from '../router'

Expand All @@ -15,4 +16,5 @@ export function registerPlugins (app: App) {
app
.use(vuetify)
.use(router)
.use(createPinia())
}
2 changes: 1 addition & 1 deletion src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const router = createRouter({
})

// Workaround for https://github.com/vitejs/vite/issues/11804
router.onError((err, to) => {
router.onError((err: any, to: any) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
if (!localStorage.getItem('vuetify:dynamic-reload')) {
console.log('Reloading page to fix dynamic import error')
Expand Down
54 changes: 54 additions & 0 deletions src/stores/rocketStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import axios from 'axios'

export interface Rocket {
id: string
name: string
description: string
flickr_images: string[]
cost_per_launch: number
country: string
first_flight: string
}

export const useRocketStore = defineStore('rocket', {
state: () => ({
rockets: [] as Rocket[],
isLoading: false,
error: null as string | null,
searchQuery: ''
}),
getters: {
filteredRockets(state) {
if (!state.searchQuery) return state.rockets
const query = state.searchQuery.toLowerCase()
// Filter rockets by name or description
return state.rockets.filter(r =>
r.name.toLowerCase().includes(query) ||
r.description.toLowerCase().includes(query)
)
}
},
actions: {
async fetchRockets() {
// Avoid re-fetching if data already exists and no error occurred
if (this.rockets.length > 0 && !this.error) return

this.isLoading = true
this.error = null

try {
const response = await axios.get<Rocket[]>('https://api.spacexdata.com/v4/rockets')
this.rockets = response.data
} catch (err: any) {
this.error = 'Failed to load rockets. ' + (err.message || 'Unknown error')
} finally {
this.isLoading = false
}
},
addManualRocket(rocket: Rocket) {
// Simulating "Add new rocket locally" by pushing it unshifted to states array
this.rockets.unshift(rocket)
}
}
})