First commit

This commit is contained in:
TDLaouer 2025-07-09 01:00:28 +02:00
commit bdecfce144
41 changed files with 7956 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

8
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:lts-alpine
RUN npm install -g http-server
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN npm run build
EXPOSE 8080
CMD [ "http-server", "dist" ]

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# Smash or Pass Frontend in Vue
Smash or Pass ?
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
eslint.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Smash or Pass App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5766
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "smash-or-pass-vue",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"axios": "^1.10.0",
"pinia": "^3.0.3",
"vue": "^3.5.13",
"vue-filepond": "^7.0.4",
"vue-router": "^4.5.1",
"vue3-toastify": "^0.2.8"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0",
"jiti": "^2.4.2",
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

54
src/App.vue Normal file
View File

@ -0,0 +1,54 @@
<script setup lang="ts"></script>
<template>
<div class="container">
<main>
<router-view :key="$route.fullPath"></router-view>
</main>
</div>
</template>
<style scoped>
html,
body {
padding: 0;
margin: 0;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
header {
display: flex;
align-items: center;
flex-direction: column;
padding: 2rem 0;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
/* @media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
} */
</style>

100
src/assets/base.css Normal file
View File

@ -0,0 +1,100 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* custom colors */
:root {
--button-color-white: #f4edde;
--button-color-black: #000000;
--button-background-color-white: #f4edde;
--button-background-color-black: #000000;
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--button-background-color: var(--button-background-color-black);
--button-color: var(--button-color-white);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
--button-background-color: var(--button-background-color-white);
--button-color: var(--button-color-black);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

49
src/assets/main.css Normal file
View File

@ -0,0 +1,49 @@
@import './base.css';
.button {
position: relative;
font-size: 17px;
text-transform: uppercase;
text-decoration: none;
padding: 1em 2.5em;
display: inline-block;
cursor: pointer;
border-radius: 6em;
transition: all 0.2s;
border: none;
font-family: inherit;
font-weight: 600;
color: var(--button-color);
background-color: var(--button-background-color);
}
.button:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.button:active {
transform: translateY(-1px);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
}
.button::after {
content: "";
display: inline-block;
height: 100%;
width: 100%;
border-radius: 100px;
position: absolute;
top: 0;
left: 0;
z-index: -1;
transition: all 0.4s;
}
.button::after {
background-color: var(--button-background-color);
}
.button:hover::after {
transform: scaleX(1.4) scaleY(1.6);
opacity: 0;
}

101
src/components/Card.vue Normal file
View File

@ -0,0 +1,101 @@
<template>
<div
class="swipe-card"
:class="{ 'slide-right': animateTo === 'smash', 'slide-left': animateTo === 'pass' }"
>
<img
:src="image.imageSrc"
:alt="image.name"
class="card-image"
@click="openImageInNewTab"
style="cursor: pointer"
/>
<div class="controls">
<h2>{{ image.name }}</h2>
<div>
<button class="button button-pass" @click="choose('pass')">PASS</button>
<button class="button button-smash" @click="choose('smash')">SMASH</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { FrontVisualAsset, VisualAsset } from '@/types/types.ts'
const props = defineProps({
image: {
type: Object as () => FrontVisualAsset,
required: true,
},
})
const emit = defineEmits(['choose'])
const animateTo = ref<string | null>(null)
const choose = (choice: string) => {
animateTo.value = choice
setTimeout(() => {
emit('choose', choice)
animateTo.value = null
}, 500) // Match animation duration
}
const openImageInNewTab = () => {
window.open(props.image.imageSrc, '_blank')?.focus()
}
</script>
<style scoped>
.swipe-card {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
transition:
transform 0.5s ease-out,
opacity 0.5s ease;
}
.card-image {
width: 100%;
height: auto;
max-width: 45vw;
object-fit: cover;
}
.controls {
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
flex-direction: column;
gap: 2rem;
padding: 1rem 0;
background: rgba(0, 0, 0, 0.5);
}
.slide-right {
transform: translateX(200px);
opacity: 0;
}
.slide-left {
transform: translateX(-200px);
opacity: 0;
}
button {
--button-color: #000000;
}
.button-pass {
--button-background-color: #8324de;
margin: 1rem;
}
.button-smash {
--button-background-color: #b9d532;
margin: 1rem;
}
</style>

47
src/components/Game.vue Normal file
View File

@ -0,0 +1,47 @@
<template>
<div v-if="visualAssetStore.visualAssets.length">
<Card
v-if="currentIndex < visualAssetStore.visualAssets.length"
:image="visualAssetStore.visualAssets[currentIndex]"
@choose="handleChoice"
/>
<Result
v-else
:smashList="smashList"
:passList="passList"
@restart="restart"
/>
</div>
</template>
<script setup lang="ts">
import Card from './Card.vue'
import Result from './Result.vue'
import { ref } from 'vue'
import type { FrontVisualAsset, VisualAsset } from '@/types/types'
import { useVisualAssetStore } from '@/stores/visualAssetStore';
import router from '@/route';
const visualAssetStore = useVisualAssetStore();
const currentIndex = ref(0)
const smashList = ref<FrontVisualAsset[]>([])
const passList = ref<FrontVisualAsset[]>([])
const handleChoice = (choice: string) => {
if (choice === 'smash') {
smashList.value.push(visualAssetStore.visualAssets[currentIndex.value])
} else {
passList.value.push(visualAssetStore.visualAssets[currentIndex.value])
}
currentIndex.value++
}
const restart = () => {
currentIndex.value = 0
smashList.value = []
passList.value = []
visualAssetStore.clear()
router.push({ name: 'landing-page' })
}
</script>

View File

@ -0,0 +1,93 @@
<template>
<div class="flex-container">
<h1>Smash or Pass</h1>
<ul class="collection-list">
<li class="collection-item" v-for="(collection, name) in groupedAssets" :key="name">
<span class="collection-name">{{ name }}</span>
<button class="button button-start" @click="startGame(name)">Start</button>
</li>
</ul>
<UploadImages></UploadImages>
</div>
</template>
<script lang="ts" setup>
import { fetchVisualAssets, fetchVisualAssetsByCollection } from '@/services/visualAssetService'
import UploadImages from './UploadImages.vue'
import { computed, onMounted, ref } from 'vue'
import type { FrontVisualAsset } from '@/types/types'
import { useVisualAssetStore } from '@/stores/visualAssetStore'
import router from '@/route'
const assets = ref<FrontVisualAsset[]>([])
const visualAssetStore = useVisualAssetStore()
onMounted(() => {
fetchVisualAssets()
.then((fetchedAssets) => {
assets.value = fetchedAssets
console.log('Fetched visual assets:', assets.value)
})
.catch((error) => {
console.error('Error fetching visual assets:', error)
})
})
const groupedAssets = computed(() => {
const groupByCollection: Record<string, FrontVisualAsset[]> = {}
for (const asset of assets.value) {
if (!groupByCollection[asset.collection]) {
groupByCollection[asset.collection] = []
}
groupByCollection[asset.collection].push(asset)
}
return groupByCollection
})
const startGame = (collection: string) => {
visualAssetStore.setCollection(collection)
visualAssetStore.setVisualAssets(assets.value.filter((asset) => asset.collection === collection))
router.push({ name: 'game' })
}
</script>
<style scoped>
.flex-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.collection-list {
width: 100%;
max-width: 400px;
padding: 0;
margin: 1.5rem 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 1rem;
}
.collection-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #f4edde;
border-radius: 8px;
padding: 0.75rem 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.collection-name {
font-weight: 600;
font-size: 1.1rem;
color: #333;
}
.button-start {
--button-background-color: #8324de;
}
</style>

74
src/components/Login.vue Normal file
View File

@ -0,0 +1,74 @@
<template>
<div class="login-container">
<h2>Login</h2>
<form @submit.prevent="login">
<div class="form-group">
<label for="username">Username</label>
<input id="username" v-model="username" type="text" required />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" v-model="password" type="password" required />
</div>
<button class="button" type="submit">Login</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const username = ref('')
const password = ref('')
const error = ref('')
const login = () => {
if (username.value === 'admin' && password.value === 'password') {
error.value = ''
// Replace with your actual login logic or navigation
alert('Login successful!')
} else {
error.value = 'Invalid username or password'
}
}
</script>
<style scoped>
.login-container {
max-width: 350px;
margin: 3rem auto;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
text-align: center;
}
.form-group {
margin-bottom: 1.25rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
input[type='text'],
input[type='password'] {
width: 100%;
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 1rem;
}
.button {
width: 100%;
background: #4f8cff;
}
.button:hover {
background: #2563eb;
}
.error {
color: #e74c3c;
margin-top: 1rem;
}
</style>

108
src/components/Result.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="result">
<h2>Results</h2>
<div class="lists">
<div class="list" v-if="smashList.length > 0">
<h3>Smash List</h3>
<ul class="image-grid">
<li v-for="image in smashList" :key="image.name">
<img :src="image.imageSrc" :alt="image.name" width="100" />
</li>
</ul>
</div>
<div class="list" v-if="passList.length > 0">
<h3>Pass List</h3>
<ul class="image-grid">
<li v-for="image in passList" :key="image.name">
<img :src="image.imageSrc" :alt="image.name" width="100" />
</li>
</ul>
</div>
</div>
<button class="button" @click="$emit('restart')">Restart</button>
</div>
</template>
<script setup lang="ts">
import type { FrontVisualAsset } from '@/types/types.ts';
defineProps({
smashList: {
type: Array<FrontVisualAsset>,
required: true,
},
passList: {
type: Array<FrontVisualAsset>,
required: true,
},
});
</script>
<style scoped>
.result {
text-align: center;
padding: 2rem;
}
.lists {
display: flex;
gap: 2rem;
justify-content: space-between;
margin: 1rem 0;
}
.list {
flex: 1 1 0;
max-width: 38vw;
min-width: 250px;
display: flex;
flex-direction: column;
align-items: center;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.image-grid li {
display: flex;
justify-content: center;
align-items: center;
}
.image-grid img {
width: 100%;
height: auto;
max-width: 100%;
min-width: 60px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
object-fit: contain;
aspect-ratio: 1/1;
}
/* Responsive: smaller grids on phone */
@media (max-width: 900px) {
.lists {
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.list {
max-width: 70vw;
min-width: unset;
}
.image-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
}
@media (max-width: 600px) {
.image-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="upload-flex">
<input type="text" v-model="collection" placeholder="Collection Name" />
<Vue3Dropzone ref="dropzoneRef" v-model="files" multiple :max-files="500" />
<button class="button" @click="submit">Submit</button>
</div>
</template>
<script lang="ts" setup>
import { upload } from '@/services/visualAssetService'
import { toast } from 'vue3-toastify'
import { ref } from 'vue'
import type { VisualAsset } from '@/types/types'
import Vue3Dropzone from './dropzone/Vue3Dropzone.vue'
import { parseImageName, toTitleCaseWithSpaces } from '@/utils'
interface DropzoneFile {
name: string
file: File
}
const files = ref<DropzoneFile[]>([])
const collection = ref('')
const dropzoneRef = ref()
const clearDropzone = () => {
dropzoneRef.value?.clearAll()
}
const submit = () => {
if (files.value.length === 0) {
toast('Please submit some images before submitting.', {
type: 'error',
})
return
}
if (collection.value.trim() === '') {
toast('Please enter a collection name.', {
type: 'error',
})
return
}
const visualAssets: VisualAsset[] = []
files.value.forEach((file) => {
console.log('og file', file)
visualAssets.push({
name: toTitleCaseWithSpaces(parseImageName(file.name)),
image: file.file,
collection: collection.value,
})
})
upload(visualAssets)
.then(() => {
toast('Images uploaded successfully!', {
type: 'success',
})
files.value = []
collection.value = ''
clearDropzone()
})
.catch((error) => {
toast('Error uploading images: ' + error.message, {
type: 'error',
})
console.error('Error uploading images:', error)
})
}
</script>
<style scoped>
.upload-flex {
display: flex;
flex-direction: column;
align-items: center;
}
.upload-flex > * {
padding: 1rem 0;
width: 100%;
max-width: 800px;
min-width: 600px;
box-sizing: border-box;
}
@media (max-width: 900px) {
.upload-flex > * {
max-width: 100%;
min-width: 0;
}
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'xls'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-file-type-xls">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M4 15l4 6"/>
<path d="M4 21l4 -6"/>
<path
d="M17 20.25c0 .414 .336 .75 .75 .75h1.25a1 1 0 0 0 1 -1v-1a1 1 0 0 0 -1 -1h-1a1 1 0 0 1 -1 -1v-1a1 1 0 0 1 1 -1h1.25a.75 .75 0 0 1 .75 .75"/>
<path d="M11 15v6h3"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'txt'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-file-type-txt">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M16.5 15h3"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M4.5 15h3"/>
<path d="M6 15v6"/>
<path d="M18 15v6"/>
<path d="M10 15l4 6"/>
<path d="M10 21l4 -6"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'doc' || name === 'docx'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-file-type-doc">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M5 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/>
<path d="M20 16.5a1.5 1.5 0 0 0 -3 0v3a1.5 1.5 0 0 0 3 0"/>
<path d="M12.5 15a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1 -3 0v-3a1.5 1.5 0 0 1 1.5 -1.5z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'pdf'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-file-type-pdf">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6"/>
<path d="M17 18h2"/>
<path d="M20 15h-3v6"/>
<path d="M11 15v6h1a2 2 0 0 0 2 -2v-2a2 2 0 0 0 -2 -2h-1z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'csv'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-file-type-csv">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4"/>
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4"/>
<path d="M7 16.5a1.5 1.5 0 0 0 -3 0v3a1.5 1.5 0 0 0 3 0"/>
<path
d="M10 20.25c0 .414 .336 .75 .75 .75h1.25a1 1 0 0 0 1 -1v-1a1 1 0 0 0 -1 -1h-1a1 1 0 0 1 -1 -1v-1a1 1 0 0 1 1 -1h1.25a.75 .75 0 0 1 .75 .75"/>
<path d="M16 15l2 6l2 -6"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor"
v-if="name === 'mp4' || name === 'mkv' || name === 'mpeg-4' || name === 'webm' || name === 'mov'"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-video">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z"/>
<path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"/>
</svg>
</template>
<script setup>
const props = defineProps({
name: String
})
</script>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="60"
height="60"
viewBox="0 0 24 24"
fill="none"
style="flex-shrink: 0"
>
<path
opacity=".4"
d="M22 7.81v6.09l-1.63-1.4c-.78-.67-2.04-.67-2.82 0l-4.16 3.57c-.78.67-2.04.67-2.82 0l-.34-.28c-.71-.62-1.84-.68-2.64-.14l-4.92 3.3-.11.08c-.37-.8-.56-1.75-.56-2.84V7.81C2 4.17 4.17 2 7.81 2h8.38C19.83 2 22 4.17 22 7.81Z"
fill="#c3c3c3"
></path>
<path
d="M9.001 10.381a2.38 2.38 0 1 0 0-4.76 2.38 2.38 0 0 0 0 4.76ZM21.999 13.899v2.29c0 3.64-2.17 5.81-5.81 5.81h-8.38c-2.55 0-4.39-1.07-5.25-2.97l.11-.08 4.92-3.3c.8-.54 1.93-.48 2.64.14l.34.28c.78.67 2.04.67 2.82 0l4.16-3.57c.78-.67 2.04-.67 2.82 0l1.63 1.4Z"
fill="#c3c3c3"
></path>
</svg>
</template>

View File

@ -0,0 +1,284 @@
<template>
<div
class="preview-container"
:class="previewWrapperClasses"
@click="$emit('click', $event)"
>
<slot
name="preview"
v-for="item in files"
:key="item.id"
:data="item"
:formatSize="formatSize"
:removeFile="removeFileBuiltIn"
>
<div
class="preview"
:class="{
preview__multiple: multiple,
preview__file: item.type === 'file' && item.file && item.file.type && !item.file.type.includes('image/'),
}"
:style="{
width: `${imgWidth} !important`,
height: `${imgHeight} !important`,
}"
@click.stop
>
<!-- For actual File objects -->
<img
:src="item.src"
:alt="item.name || (item.file && item.file.name)"
v-if="item.type === 'file' && item.file && item.file.type && item.file.type.includes('image/')"
@click.stop
/>
<!-- For URL previews -->
<img
:src="item.src"
:alt="item.name"
v-if="item.type === 'url'"
@click.stop
/>
<!-- File type icon for non-images -->
<Icon
:name="item.file ? item.file.name.split('.').pop() : 'file'"
v-if="item.type === 'file' && item.file && item.file.type && (!item.file.type.includes('image/') && !item.file.type.includes('video/'))"
@click.stop
/>
<!-- File details overlay -->
<div class="img-details" v-if="allowSelectOnPreview && mode !== 'preview'" @click.stop>
<button class="img-remove" @click.stop="removeFile(item)">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler icons-tabler-outline icon-tabler-x"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M18 6l-12 12"/>
<path d="M6 6l12 12"/>
</svg>
</button>
<h2 v-if="item.name || (item.file && item.file.name)">{{ item.name || item.file.name }}</h2>
<span v-if="item.size || (item.file && item.file.size)">{{ formatSize(item.size || item.file.size) }}</span>
</div>
<!-- Progress bar for file uploads -->
<div
class="progress-bar-container"
v-if="item.type === 'file' && (item.status === 'pending' || item.status === 'uploading')"
>
<div
class="progress-bar"
:aria-valuenow="item.progress"
aria-valuemin="0"
aria-valuemax="100"
:style="{ width: `${item.progress}%` }"
>
{{ item.progress }}%
</div>
</div>
</div>
</slot>
</div>
</template>
<script setup>
import Icon from "./Icon.vue";
const props = defineProps({
files: {
type: Array,
default: [],
},
previewUrls: {
type: Array,
default: [], // Legacy prop, kept for backward compatibility
},
multiple: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: "drop",
validator(value) {
return ["drop", "preview", "edit"].includes(value);
},
},
allowSelectOnPreview: Boolean,
imgWidth: [Number, String],
imgHeight: [Number, String],
previewWrapperClasses: String,
removeFileBuiltIn: Function
});
const emit = defineEmits(["removeFile", "click"]);
// Formats file size
const formatSize = (size) => {
if (!size) return "Unknown size";
const i = Math.floor(Math.log(size) / Math.log(1024));
return (
(size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "KB", "MB", "GB"][i]
);
};
// Removes file from files list
const removeFile = (item) => {
emit("removeFile", item);
};
</script>
<style scoped>
.preview-container {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
gap: 40px;
}
.preview {
width: 100%;
height: 100%;
border-radius: 8px;
flex-shrink: 0;
position: relative;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.preview__multiple {
height: 150px;
width: 150px;
}
.preview__file {
border: 1px dashed rgba(var(--v3-dropzone--primary));
}
.preview__file--error {
border-color: rgba(var(--v3-dropzone--error)) !important;
}
.preview:hover .img-details {
opacity: 1 !important;
visibility: visible !important;
}
.preview img {
width: 100%;
height: 100%;
border-radius: 8px;
object-fit: cover;
}
.img-details {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
background: rgba(
var(--v3-dropzone--overlay),
var(--v3-dropzone--overlay-opacity)
);
border-radius: 8px;
transition: all 0.2s linear;
-webkit-backdrop-filter: blur(7px);
backdrop-filter: blur(7px);
filter: grayscale(1%);
opacity: 0;
visibility: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.img-details h2 {
font-size: 14px;
font-weight: 400;
text-align: center;
color: #fff;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 400px) {
.img-details h2 {
max-width: 200px;
}
}
.img-details span {
font-size: 12px;
font-weight: 600;
text-align: center;
color: #fff;
}
.img-remove {
background: rgba(var(--v3-dropzone--error));
border-radius: 10px;
border: none;
padding: 5px;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 10px;
right: 10px;
transition: all 0.2s linear;
}
.img-remove:active {
transform: scale(0.9);
}
.img-remove:hover {
background: rgba(var(--v3-dropzone--error), 0.8);
}
.progress-bar-container {
position: absolute;
bottom: 0;
background-color: #666;
border-radius: 5px;
overflow: hidden;
width: 100%;
height: 10px;
}
.progress-bar {
height: 100%;
background-color: rgba(var(--v3-dropzone--primary));
text-align: center;
font-size: 10px;
line-height: 10px;
color: #fff;
width: 0;
transition: width 0.5s ease-in-out;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<Preview v-bind="previewProps" @click="$emit('click', $event)" @mouseover="$emit('mouseover', $event)" @mouseleave="$emit('mouseleave', $event)">
<template #preview="slotProps">
<slot name="preview" v-bind="slotProps"></slot>
</template>
</Preview>
</template>
<script setup>
import {computed} from 'vue';
import Preview from './Preview.vue';
const props = defineProps({
files: Array,
previewUrls: Array,
multiple: Boolean,
mode: String,
allowSelectOnPreview: Boolean,
imgWidth: [Number, String],
imgHeight: [Number, String],
previewWrapperClasses: [String, Array, Object]
});
const emit = defineEmits(['removeFile', 'click', 'mouseover', 'mouseleave']);
const previewProps = computed(() => ({
...props,
onRemoveFile: (file) => emit('removeFile', file)
}));
</script>

View File

@ -0,0 +1,623 @@
<template>
<div class="dropzone">
<div
class="dropzone-wrapper"
:style="{ width, height }"
@dragenter.prevent="toggleActive"
@dragleave.prevent="toggleActive"
@drop.prevent="drop"
@dragover.prevent
@mouseover="hover"
@mouseleave="blurDrop"
:class="[
{
'dropzone-wrapper--active': active,
'dropzone-wrapper--disabled': disabled,
},
state ? `dropzone-wrapper--${state}` : '',
]"
ref="dropzoneWrapper"
@click.self="openSelectFile"
id="dropzoneWrapper"
>
<!-- Input -->
<input
type="file"
ref="fileInput"
class="hidden"
:id="fileInputId"
:accept="accept"
@input="inputFiles"
:multiple="multiple"
/>
<!-- Placeholder content -->
<template v-if="!unifiedItems.length || previewPosition === 'outside'">
<slot name="placeholder-img">
<PlaceholderImage/>
</slot>
<slot name="title">
<div class="titles">
<h1 class="m-0">Drop your files here</h1>
</div>
</slot>
<slot name="button" :fileInput="fileInput">
<button
@click="fileInput?.click()"
v-if="showSelectButton"
class="select-file"
>
Select File
</button>
</slot>
<slot name="description">
<p class="m-0 description">
Files must be under {{ maxFileSize }}MB
{{ accept ? `and in ${accept} formats` : "" }}
</p>
</slot>
</template>
<!-- Files previews inside -->
<PreviewSlot
v-if="previewPosition === 'inside' && unifiedItems.length"
v-bind="previewProps"
@removeFile="removeFile"
@click="fileInputAllowed && openSelectFile($event)"
@mouseover="fileInputAllowed ? hover : undefined"
@mouseleave="fileInputAllowed ? blurDrop : undefined"
>
<template #preview="previewProps">
<slot name="preview" v-bind="previewProps"></slot>
</template>
</PreviewSlot>
</div>
<div
class="dropzone-wrapper__disabled"
@click.prevent
@drop.prevent
@dragover.prevent
v-if="disabled"
></div>
<!-- Files previews outside -->
<div class="mt-5"
v-if="previewPosition === 'outside' && unifiedItems.length">
<PreviewSlot
v-bind="previewProps"
@removeFile="removeFile"
@click="fileInputAllowed && openSelectFile($event)"
@mouseover="fileInputAllowed ? hover : undefined"
@mouseleave="fileInputAllowed ? blurDrop : undefined"
>
<template #preview="previewProps">
<slot name="preview" v-bind="previewProps"></slot>
</template>
</PreviewSlot>
</div>
</div>
</template>
<script setup>
import {computed, ref, watchEffect} from "vue";
import PlaceholderImage from "./PlaceholderImage.vue";
import PreviewSlot from "./PreviewSlot.vue";
const props = defineProps({
modelValue: {
type: Array,
default: [],
},
multiple: {
type: Boolean,
default: false,
},
previews: {
type: Array,
default: [],
},
mode: {
type: String,
default: "drop",
validator(value) {
return ["drop", "preview", "edit"].includes(value);
},
},
disabled: {
type: Boolean,
default: false,
},
state: {
type: String,
validator(value) {
return ["error", "success", "indeterminate"].includes(value);
},
default: 'indeterminate'
},
accept: String,
maxFileSize: {
type: Number,
default: 5,
},
maxFiles: {
type: Number,
default: 5,
},
width: [Number, String],
height: [Number, String],
imgWidth: [Number, String],
imgHeight: [Number, String],
fileInputId: String,
previewWrapperClasses: String,
previewPosition: {
type: String,
default: "inside",
validator: (value) => ["inside", "outside"].includes(value),
},
showSelectButton: {
type: Boolean,
default: true,
},
selectFileStrategy: {
type: String,
default: "replace",
validator: (value) => ["replace", "merge"].includes(value),
},
serverSide: {
type: Boolean,
default: false,
},
uploadEndpoint: {
type: String,
},
deleteEndpoint: {
type: String,
},
headers: {
type: Object,
default: () => ({}),
},
allowSelectOnPreview: {
type: Boolean,
default: false
}
});
// Unified data structure that combines both File objects and URL previews
const unifiedItems = computed(() => {
const items = [];
// Add preview URLs first (existing images)
if (props.previews && props.previews.length) {
props.previews.forEach((url, index) => {
items.push({
id: `preview-${index}`,
src: url,
type: 'url',
isPreview: true,
name: `Image ${index + 1}`,
size: 0,
progress: 100,
status: 'success',
message: null
});
});
}
// Add actual file objects
if (files.value && files.value.length) {
files.value.forEach(fileItem => {
items.push({
...fileItem,
type: 'file',
isPreview: false
});
});
}
return items;
});
const previewProps = computed(() => ({
files: unifiedItems.value,
previewUrls: [], // Legacy prop, no longer used
multiple: props.multiple,
mode: props.mode,
allowSelectOnPreview: props.mode !== "preview" || props.allowSelectOnPreview,
imgWidth: props.imgWidth,
imgHeight: props.imgHeight,
previewWrapperClasses: props.previewWrapperClasses,
removeFileBuiltIn: removeFile
}));
const emit = defineEmits([
"drop",
"update:modelValue",
"update:previews",
"error",
"fileUploaded",
"fileRemoved",
"previewRemoved",
]);
const fileInput = ref(null);
const files = ref([]);
const active = ref(false);
const dropzoneWrapper = ref(null);
const fileInputId = computed(() => {
if (props.fileInputId) return props.fileInputId;
return generateFileId();
});
const fileInputAllowed = computed(() => {
return !props.disabled && (props.mode === "drop" || (props.mode === "preview" && props.allowSelectOnPreview) || props.mode === "edit")
})
const generateFileId = () => {
return Math.floor(Math.random() * Math.floor(Math.random() * Date.now()));
};
// Manages input files
const inputFiles = (e) => {
const allFiles = [...e.target.files].slice(0, props.maxFiles);
const filesSizesAreValid = allFiles.map((item) => {
const itemSize = (item.size / 1024 / 1024).toFixed(2);
return itemSize <= props.maxFileSize;
});
const filesTypesAreValid = allFiles.map((item) => {
if (props.accept) {
return props.accept.includes(item.type);
}
return [];
});
if (
(filesSizesAreValid.every((item) => item === true) &&
props.accept &&
filesTypesAreValid.every((item) => item === true)) ||
!props.accept && filesSizesAreValid.every((item) => item === true)
) {
const processFile = (file) => ({
file: file,
id: generateFileId(),
src: URL.createObjectURL(file),
progress: 0,
status: "pending",
message: null,
name: file.name,
size: file.size,
type: 'file',
isPreview: false
});
// Use selectFileStrategy for all modes
const strategy = props.selectFileStrategy;
if (strategy === "replace") {
files.value = allFiles.map(processFile);
// In edit mode, also clear previews if replacing
if (props.mode === "edit") {
emit("update:previews", []);
}
}
if (strategy === "merge") {
files.value = [...files.value, ...allFiles.map(processFile)];
}
}
if (filesSizesAreValid.some((item) => item !== true)) {
const largeFiles = allFiles.filter((item) => {
const itemSize = (item.size / 1024 / 1024).toFixed(2);
return itemSize > props.maxFileSize;
});
handleFileError("file-too-large", largeFiles);
}
if (props.accept && filesTypesAreValid.some((item) => item !== true)) {
const wrongTypeFiles = allFiles.filter(
(item) => !props.accept.includes(item.type)
);
handleFileError("invalid-file-format", wrongTypeFiles);
}
files.value
.filter((fileItem) => fileItem.status !== "success")
.forEach((fileItem) => {
// Upload files to server
if (props.serverSide) {
uploadFileToServer(fileItem);
} else {
fileItem.progress = 100;
fileItem.status = "success";
fileItem.message = "File uploaded successfully";
emit("fileUploaded", {file: fileItem});
}
});
};
// Upload file to server
const uploadFileToServer = (fileItem) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", props.uploadEndpoint || '', true);
// Set headers
Object.keys(props.headers).forEach((key) => {
xhr.setRequestHeader(key, props.headers[key]);
});
const formData = new FormData();
formData.append("file", fileItem.file);
// Start upload
xhr.upload.onloadstart = () => {
fileItem.status = "uploading";
fileItem.message = "Upload in progress";
};
// Upload progress
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
fileItem.progress = Math.round((event.loaded / event.total) * 100);
}
};
// Upload success
xhr.onload = () => {
if (xhr.status === 200) {
fileItem.status = "success";
fileItem.message = "File uploaded successfully";
emit("fileUploaded", {file: fileItem});
} else {
fileItem.status = "error";
fileItem.message = xhr.statusText;
handleFileError("upload-error", [fileItem.file]);
}
};
// Upload error
xhr.onerror = () => {
fileItem.status = "error";
fileItem.message = "Upload failed";
handleFileError("upload-error", [fileItem.file]);
};
// Send file to server
xhr.send(formData);
};
// Toggles active state for dropping files(styles)
const toggleActive = () => {
if (fileInputAllowed.value) {
active.value = !active.value;
}
};
// Handles dropped files and input them
const drop = (e) => {
toggleActive();
if (fileInputAllowed.value) {
const files = {
target: {
files: [...e.dataTransfer.files],
},
};
emit("drop", e);
inputFiles(files);
}
};
// Enhanced removeFile to handle both types
const removeFile = (item) => {
if (item.type === 'url' || item.isPreview) {
// Remove from previews array
const currentPreviews = [...props.previews];
const previewIndex = parseInt(item.id.replace('preview-', ''));
currentPreviews.splice(previewIndex, 1);
emit("update:previews", currentPreviews);
emit("previewRemoved", item);
} else {
// Handle file removal
if (props.serverSide) {
removeFileFromServer(item);
} else {
removeFileFromList(item);
}
}
};
const removeFileFromServer = (item) => {
const xhr = new XMLHttpRequest();
xhr.open("DELETE", props.deleteEndpoint ? `${props.deleteEndpoint}/${item.id}` : '', true);
// Set headers
Object.keys(props.headers).forEach((key) => {
xhr.setRequestHeader(key, props.headers[key]);
});
xhr.onload = () => {
if (xhr.status === 200) {
removeFileFromList(item);
} else {
handleFileError("delete-error", [item]);
}
};
xhr.onerror = () => {
handleFileError("delete-error", [item]);
};
xhr.send();
};
const removeFileFromList = (item) => {
files.value = files.value.filter((file) => file.id !== item.id);
fileInput.value.value = "";
emit("fileRemoved", item);
emit("update:modelValue", files.value);
};
// Hover and blur manager
const hover = () => {
if (fileInputAllowed.value) {
active.value = true;
}
};
const blurDrop = () => {
active.value = false;
};
const testclick = () => {
console.log('testclick');
}
// Opens os selecting file window
const openSelectFile = (e) => {
console.log('asd');
if (fileInputAllowed.value) {
fileInput.value.click();
} else {
e.preventDefault();
}
};
// Handles file errors
const handleFileError = (type, files) => {
emit("error", {type: type, files: files});
};
watchEffect(() => {
if (files.value && files.value.length) {
emit("update:modelValue", files.value);
}
});
const clearPreview = () => {
unifiedItems.value.forEach((item) => removeFile(item));
};
// Public methods for programmatic control
const clearFiles = () => {
files.value = [];
emit("update:modelValue", []);
};
const clearPreviews = () => {
emit("update:previews", []);
};
const clearAll = () => {
clearFiles();
clearPreviews();
};
defineExpose({
clearPreview,
clearFiles,
clearPreviews,
clearAll,
});
</script>
<style scoped>
* {
font-family: sans-serif;
}
.m-0 {
margin: 0;
}
.mt-5 {
margin-top: 3rem;
}
.dropzone {
--v3-dropzone--primary: 94, 112, 210;
--v3-dropzone--border: 214, 216, 220;
--v3-dropzone--description: 190, 191, 195;
--v3-dropzone--overlay: 40, 44, 53;
--v3-dropzone--overlay-opacity: 0.3;
--v3-dropzone--error: 255, 76, 81;
--v3-dropzone--success: 36, 179, 100;
position: relative;
display: flex;
flex-direction: column;
}
.hidden {
display: none;
}
.dropzone-wrapper {
border: 2px dashed rgba(var(--v3-dropzone--border));
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
width: auto;
height: 200px;
transition: 0.3s all ease;
justify-content: center;
}
.dropzone-wrapper--disabled {
opacity: 0.5;
}
.dropzone-wrapper__disabled {
position: absolute;
top: -2px;
inset-inline-start: -2px;
width: calc(100% + 4px);
height: calc(100% + 4px);
border-radius: 12px;
background: transparent;
z-index: 2;
}
.dropzone-wrapper--active {
border-color: rgba(var(--v3-dropzone--primary)) !important;
background: rgba(var(--v3-dropzone--primary), 0.1) !important;
}
.dropzone-wrapper--error {
border-color: rgba(var(--v3-dropzone--error)) !important;
}
.dropzone-wrapper--success {
border-color: rgba(var(--v3-dropzone--success)) !important;
}
.select-file {
background: rgba(var(--v3-dropzone--primary));
border-radius: 10px;
font-weight: 500;
font-size: 12px;
border: none;
padding: 10px 20px;
color: #fff;
cursor: pointer;
margin-bottom: 10px;
margin-top: 10px;
}
.description {
font-size: 12px;
color: rgba(var(--v3-dropzone--description));
}
.titles {
text-align: center;
}
.titles h1 {
font-weight: 400;
font-size: 20px;
}
.titles h3 {
margin-top: 30px;
font-weight: 400;
}
</style>

14
src/main.ts Normal file
View File

@ -0,0 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import router from './route'
import App from './App.vue'
import 'vue3-toastify/dist/index.css'
import axios from 'axios'
import { createPinia } from 'pinia'
axios.defaults.baseURL = 'http://localhost:3000'
const pinia = createPinia()
createApp(App).use(router).use(pinia).mount('#app')

35
src/route.ts Normal file
View File

@ -0,0 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'landing-page',
component: () => import('@/components/LandingPage.vue'),
meta: {
title: 'Landing Page',
},
},
{
path: '/game',
name: 'game',
props: true,
component: () => import('@/components/Game.vue'),
meta: {
title: 'Smash or Pass Game',
},
},
{
path: '/login',
name: 'login',
props: true,
component: () => import('@/components/Login.vue'),
meta: {
title: 'Login',
},
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router

View File

View File

@ -0,0 +1,55 @@
import axios from 'axios'
import type { FrontVisualAsset, VisualAsset } from '@/types/types.ts'
export const upload = async (visualAssets: VisualAsset[]): Promise<void> => {
try {
for (const asset of visualAssets) {
const formData = new FormData()
console.log('asset file', asset.image)
formData.append('name', asset.name)
formData.append('collection', asset.collection)
formData.append('image', asset.image)
await axios.post('/api/visualAssets', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
} catch (error) {
console.error('Error uploading visual assets:', error)
throw error
}
}
export const fetchVisualAssets = async (): Promise<FrontVisualAsset[]> => {
try {
const response = await axios.get('/api/visualAssets')
return response.data as FrontVisualAsset[]
} catch (error) {
console.error('Error fetching visual assets:', error)
throw error
}
}
export const fetchVisualAssetsByCollection = async (
collection: string,
): Promise<FrontVisualAsset[]> => {
try {
const response = await axios.get(`/api/visualAssets/collection/${collection}`)
return response.data as FrontVisualAsset[]
} catch (error) {
console.error('Error fetching visual assets:', error)
throw error
}
}
export const fetchVisualAssetById = async (id: string): Promise<FrontVisualAsset> => {
try {
const response = await axios.get(`/api/visualAssets/${id}`)
return response.data as FrontVisualAsset
} catch (error) {
console.error(`Error fetching visual asset with id ${id}:`, error)
throw error
}
}

7
src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@meforma/vue-toaster';

32
src/stores/userStore.ts Normal file
View File

@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref<string | null>(localStorage.getItem('user') || null)
const isLoggedIn = ref<boolean>(!!user.value)
function login(username: string) {
user.value = username
isLoggedIn.value = true
localStorage.setItem('user', username)
}
function logout() {
user.value = null
isLoggedIn.value = false
localStorage.removeItem('user')
}
// Keep session persistent if user changes
watch(user, (newUser) => {
if (newUser) {
localStorage.setItem('user', newUser)
isLoggedIn.value = true
} else {
localStorage.removeItem('user')
isLoggedIn.value = false
}
})
return { user, isLoggedIn, login, logout }
})

View File

@ -0,0 +1,26 @@
import type { FrontVisualAsset, VisualAsset } from '@/types/types'
import { defineStore } from 'pinia'
export const useVisualAssetStore = defineStore('visualAsset', {
state: () => ({
collection: '' as string,
visualAssets: [] as FrontVisualAsset[],
selectedVisualAsset: null as FrontVisualAsset | null,
}),
actions: {
setCollection(collection: string) {
this.collection = collection
},
setVisualAssets(visualAssets: FrontVisualAsset[]) {
this.visualAssets = visualAssets
},
selectVisualAsset(visualAsset: FrontVisualAsset) {
this.selectedVisualAsset = visualAsset
},
clear() {
this.visualAssets = []
this.selectedVisualAsset = null
this.collection = ''
},
},
})

2
src/types/types.ts Normal file
View File

@ -0,0 +1,2 @@
export type VisualAsset = { collection: string; image: File; name: string }
export type FrontVisualAsset = { _id: string; collection: string; imageSrc: string; name: string }

13
src/utils.ts Normal file
View File

@ -0,0 +1,13 @@
export const toTitleCaseWithSpaces = (input: string): string => {
return input
.replace(/[-_]/g, ' ')
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
export const parseImageName = (fileName: string): string => {
// Extract the name without the extension
return fileName.split('.').slice(0, -1).join('.')
}

12
tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.json",
"compilerOptions": {
// this to solve some error hints
"ignoreDeprecations": "5.0",
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

15
vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})