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
24 changes: 24 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter

Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.

## Setup

Make sure to install dependencies:

```bash
# npm
npm install

# pnpm
pnpm install

# yarn
yarn install

# bun
bun install
```

## Development Server

Start the development server on `http://localhost:3000`:

```bash
# npm
npm run dev

# pnpm
pnpm dev

# yarn
yarn dev

# bun
bun run dev
```

## Production

Build the application for production:

```bash
# npm
npm run build

# pnpm
pnpm build

# yarn
yarn build

# bun
bun run build
```

Locally preview production build:

```bash
# npm
npm run preview

# pnpm
pnpm preview

# yarn
yarn preview

# bun
bun run preview
```

Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
40 changes: 40 additions & 0 deletions frontend/app/api/buckets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ApiFetch } from './main'
import type { Bucket, ObjectsAPIResponse } from '~/types'

interface BucketObjectFileUrl {
url: string
}
export interface BucketsAPI {
getBucketDetail: (id: string) => Promise<Bucket>
getBuckets: () => Promise<Bucket[]>
getBucketObjects: ({ bucketName, prefix }: { bucketName: string; prefix?: string }) => Promise<ObjectsAPIResponse>
getBucketObjectFileUrl: ({ bucketName, key }: { bucketName: string; key: string }) => Promise<BucketObjectFileUrl>
}

export default (apiFetch: ApiFetch): BucketsAPI => ({
getBucketDetail(id: string) {
return apiFetch(`/buckets/${id}`, {
method: 'GET'
})
},

getBuckets() {
return apiFetch(`/buckets`, {
method: 'GET'
})
},

getBucketObjects({ bucketName, prefix }) {
return apiFetch(`/buckets/${bucketName}/objects`, {
method: 'GET',
query: { prefix }
})
},

getBucketObjectFileUrl({ bucketName, key }) {
return apiFetch(`/buckets/${bucketName}/objects/${key}`, {
method: 'GET',
query: { presigned: true }
})
}
})
19 changes: 19 additions & 0 deletions frontend/app/api/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { BucketsAPI } from './buckets'
import bucketsApi from './buckets'

export type ApiFetch = (
url: string,
options?: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body?: any
query?: Record<string, string | number | boolean | undefined>
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<any>

export interface Api extends BucketsAPI {}

export default (apiFetch: ApiFetch): Api => ({
...bucketsApi(apiFetch),
})
12 changes: 12 additions & 0 deletions frontend/app/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
useHead({
titleTemplate: (titleChunk) => {
return titleChunk ? `${titleChunk} - Explorer451` : 'Explorer451';
}
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
123 changes: 123 additions & 0 deletions frontend/app/assets/css/main.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
@import "tailwindcss" theme(static);
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}

.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}

@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
31 changes: 31 additions & 0 deletions frontend/app/components/BucketPicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<main class="flex-1 p-6">
<h2 class="text-center">Pick a bucket</h2>
<p class="text-muted-foreground text-center">
Please select a bucket to view its contents.
</p>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<Title>Available Buckets</Title>
<NuxtLink :to="`/files/${bucket.name}`" v-for="bucket in fileStore.buckets" :key="bucket.name">
<Card class="p-4 hover:shadow-md transition-shadow cursor-pointer">
<CardContent class="flex flex-col items-center gap-2">
<div class="p-3 rounded-lg bg-muted">
<Database class="h-8 w-8 text-muted-foreground" />
</div>
<div class="text-center">
<p class="text-sm font-medium truncate w-full overflow-hidden text-ellipsis" :title="bucket.name">{{ bucket.name }}</p>
<p class="text-xs text-muted-foreground">Bucket</p>
</div>
</CardContent>
</Card>
</NuxtLink>
</div>
</main>
</template>
<script setup lang="ts">
import { Card, CardContent } from '@/components/ui/card'
import { Database } from 'lucide-vue-next'
import { Title } from '#components'
import { useFileStore } from '@/stores'
const fileStore = useFileStore()
</script>
16 changes: 16 additions & 0 deletions frontend/app/components/EmptyState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<Card>
<CardContent class="flex flex-col items-center justify-center py-12">
<Folder class="h-12 w-12 text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No items found</h3>
<p class="text-muted-foreground text-center">
{{ searchQuery ? 'No files or folders match your search criteria.' : 'This folder is empty. Upload some files or create folders to get started.' }}
</p>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { Card, CardContent } from '@/components/ui/card'
import { Folder } from 'lucide-vue-next'
const props = defineProps<{ searchQuery: string }>()
</script>
40 changes: 40 additions & 0 deletions frontend/app/components/FileDownload.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { ref } from 'vue'
import type { BucketObject } from '~/types';

const api = useNuxtApp().$api;
const props = defineProps<{
item: BucketObject,
selectedBucketName: string
}>()

const onDownloadHandle = async () => {
const data = await api.getBucketObjectFileUrl({
bucketName: props.selectedBucketName,
key: props.item.key
})
const link = document.createElement('a')
link.href = data.url
link.download = props.item.key.split('/').pop() || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const isDownloading = ref(false)
const onDownloadClick = async () => {
isDownloading.value = true
try {
await onDownloadHandle()
} catch (error) {
console.error('Download failed:', error)
} finally {
isDownloading.value = false
}
}

</script>
<template>
<div class="flex items-center gap-2" :class="{ 'cursor-not-allowed opacity-50': isDownloading }" @click="onDownloadClick">
<slot></slot>
</div>
</template>
Loading