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,134 changes: 5,134 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@mdi/font": "7.4.47",
"core-js": "^3.37.1",
"pinia": "^3.0.4",
"roboto-fontface": "*",
"vue": "^3.4.31",
"vuetify": "^3.6.14"
Expand Down
24 changes: 24 additions & 0 deletions src/api/spacex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* SpaceX API client (v4)
* @see https://github.com/r-spacex/SpaceX-API
*/
import type { Rocket } from '@/types/rocket'

const BASE_URL = 'https://api.spacexdata.com/v4'

export async function fetchRockets(): Promise<Rocket[]> {
const res = await fetch(`${BASE_URL}/rockets`)
if (!res.ok) {
throw new Error(`SpaceX API error: ${res.status} ${res.statusText}`)
}
return res.json()
}

export async function fetchRocketById(id: string): Promise<Rocket | null> {
const res = await fetch(`${BASE_URL}/rockets/${id}`)
if (res.status === 404) return null
if (!res.ok) {
throw new Error(`SpaceX API error: ${res.status} ${res.statusText}`)
}
return res.json()
}
116 changes: 116 additions & 0 deletions src/components/AddRocketDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="500"
persistent
@update:model-value="onModelUpdate"
>
<v-card>
<v-card-title class="text-h6">
Add new rocket
</v-card-title>
<v-card-text>
<v-text-field
v-model="form.name"
label="Rocket name"
variant="outlined"
density="comfortable"
class="mb-2"
hide-details
/>
<v-textarea
v-model="form.description"
label="Description"
variant="outlined"
density="comfortable"
rows="3"
class="mb-2"
hide-details
/>
<v-text-field
v-model="form.country"
label="Country (optional)"
variant="outlined"
density="comfortable"
class="mb-2"
hide-details
/>
<v-text-field
v-model="form.first_flight"
label="First flight (optional)"
variant="outlined"
density="comfortable"
placeholder="YYYY-MM-DD"
hide-details
/>
<v-text-field
v-model.number="form.cost_per_launch"
label="Cost per launch (optional)"
type="number"
variant="outlined"
density="comfortable"
class="mt-2"
hide-details
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="$emit('update:modelValue', false)"
>
Cancel
</v-btn>
<v-btn
color="primary"
:disabled="!form.name.trim()"
@click="submit"
>
Add
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script lang="ts" setup>
import { reactive } from 'vue'
import type { RocketInput } from '@/types/rocket'

const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; add: [input: RocketInput] }>()

function onModelUpdate(v: boolean) {
emit('update:modelValue', v)
if (!v) reset()
}

const form = reactive<RocketInput & { cost_per_launch?: number }>({
name: '',
description: '',
country: '',
first_flight: '',
cost_per_launch: undefined,
})

function reset() {
form.name = ''
form.description = ''
form.country = ''
form.first_flight = ''
form.cost_per_launch = undefined
}

function submit() {
if (!form.name.trim()) return
emit('add', {
name: form.name.trim(),
description: form.description?.trim() ?? '',
country: form.country?.trim() ?? '',
first_flight: form.first_flight?.trim() || null,
cost_per_launch: form.cost_per_launch ?? null,
})
emit('update:modelValue', false)
reset()
}
</script>
52 changes: 52 additions & 0 deletions src/components/RocketCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<template>
<v-card
class="rocket-card"
variant="outlined"
rounded="lg"
hover
@click="$router.push(`/rockets/${rocket.id}`)"
>
<v-img
:src="imageUrl"
:alt="rocket.name"
height="180"
cover
gradient="to bottom, rgba(0,0,0,0) 50%, rgba(0,0,0,0.7) 100%"
>
<v-card-title class="text-h6 text-white mt-auto pt-8">
{{ rocket.name }}
</v-card-title>
</v-img>
<v-card-text class="pt-3">
<p class="text-body-2 text-medium-emphasis line-clamp-3">
{{ description }}
</p>
</v-card-text>
</v-card>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import type { Rocket } from '@/types/rocket'

const props = defineProps<{ rocket: Rocket }>()

const imageUrl = computed(() => {
const imgs = props.rocket.flickr_images
return imgs?.[0] ?? 'https://imgur.com/DaCfMsj.jpg'
})

const description = computed(() => props.rocket.description ?? 'No description.')
</script>

<style scoped>
.rocket-card {
cursor: pointer;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
27 changes: 27 additions & 0 deletions src/components/UiStateError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<div class="d-flex flex-column align-center justify-center py-16">
<v-icon
size="64"
color="error"
class="mb-4"
>
mdi-alert-circle-outline
</v-icon>
<p class="text-body-1 text-medium-emphasis mb-4 text-center">
{{ message }}
</p>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-refresh"
@click="$emit('retry')"
>
Retry
</v-btn>
</div>
</template>

<script lang="ts" setup>
defineProps<{ message?: string }>()
defineEmits<{ retry: [] }>()
</script>
20 changes: 20 additions & 0 deletions src/components/UiStateLoading.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<div class="d-flex flex-column align-center justify-center py-16">
<v-progress-circular
indeterminate
color="primary"
size="64"
width="4"
/>
<p class="text-body-1 mt-4 text-medium-emphasis">
{{ message }}
</p>
</div>
</template>

<script lang="ts" setup>
withDefaults(
defineProps<{ message?: string }>(),
{ message: 'Loading...' }
)
</script>
109 changes: 107 additions & 2 deletions src/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,112 @@
<template>
<HelloWorld />
<v-container class="py-6">
<v-row>
<v-col cols="12">
<div class="d-flex align-center flex-wrap gap-4 mb-6">
<h1 class="text-h4 font-weight-bold">
Rockets
</h1>
<v-spacer />
<v-text-field
v-model="filterQuery"
placeholder="Filter by name, description, country..."
density="comfortable"
variant="outlined"
hide-details
clearable
class="filter-field"
style="max-width: 320px;"
>
<template #prepend-inner>
<v-icon size="small">
mdi-magnify
</v-icon>
</template>
</v-text-field>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
Add rocket
</v-btn>
</div>
</v-col>
</v-row>

<UiStateLoading
v-if="store.loading"
message="Loading rockets..."
/>
<UiStateError
v-else-if="store.error"
:message="store.error"
@retry="store.loadRockets()"
/>
<v-row
v-else
class="rocket-grid"
>
<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-medium-emphasis py-16"
>
No rockets match your filter.
</v-col>
</v-row>

<AddRocketDialog
v-model="showAddDialog"
@add="onAddRocket"
/>
</v-container>
</template>

<script lang="ts" setup>
//
import { ref, computed, onMounted } from 'vue'
import { useRocketsStore } from '@/stores/rockets'
import UiStateLoading from '@/components/UiStateLoading.vue'
import UiStateError from '@/components/UiStateError.vue'
import RocketCard from '@/components/RocketCard.vue'
import AddRocketDialog from '@/components/AddRocketDialog.vue'

const store = useRocketsStore()
const showAddDialog = ref(false)

const filterQuery = computed({
get: () => store.filterQuery,
set: (v) => store.setFilter(v ?? ''),
})

function onAddRocket(input: import('@/types/rocket').RocketInput) {
store.addRocket(input)
}

onMounted(() => {
if (store.rockets.length === 0 && !store.loading) {
store.loadRockets()
}
})
</script>

<style scoped>
.rocket-grid {
min-height: 200px;
}
@media (min-width: 600px) {
.filter-field {
min-width: 280px;
}
}
</style>
Loading