First commit
This commit is contained in:
commit
bdecfce144
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal 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
39
README.md
Normal 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
|
||||
```
|
||||
22
eslint.config.ts
Normal file
22
eslint.config.ts
Normal 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
13
index.html
Normal 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
5766
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
54
src/App.vue
Normal file
54
src/App.vue
Normal 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
100
src/assets/base.css
Normal 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
1
src/assets/logo.svg
Normal 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
49
src/assets/main.css
Normal 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
101
src/components/Card.vue
Normal 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
47
src/components/Game.vue
Normal 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>
|
||||
93
src/components/LandingPage.vue
Normal file
93
src/components/LandingPage.vue
Normal 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
74
src/components/Login.vue
Normal 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
108
src/components/Result.vue
Normal 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>
|
||||
94
src/components/UploadImages.vue
Normal file
94
src/components/UploadImages.vue
Normal 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>
|
||||
83
src/components/dropzone/Icon.vue
Normal file
83
src/components/dropzone/Icon.vue
Normal 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>
|
||||
20
src/components/dropzone/PlaceholderImage.vue
Normal file
20
src/components/dropzone/PlaceholderImage.vue
Normal 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>
|
||||
284
src/components/dropzone/Preview.vue
Normal file
284
src/components/dropzone/Preview.vue
Normal 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>
|
||||
30
src/components/dropzone/PreviewSlot.vue
Normal file
30
src/components/dropzone/PreviewSlot.vue
Normal 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>
|
||||
623
src/components/dropzone/Vue3Dropzone.vue
Normal file
623
src/components/dropzone/Vue3Dropzone.vue
Normal 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
14
src/main.ts
Normal 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
35
src/route.ts
Normal 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
|
||||
0
src/services/userService.ts
Normal file
0
src/services/userService.ts
Normal file
55
src/services/visualAssetService.ts
Normal file
55
src/services/visualAssetService.ts
Normal 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
7
src/shims-vue.d.ts
vendored
Normal 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
32
src/stores/userStore.ts
Normal 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 }
|
||||
})
|
||||
26
src/stores/visualAssetStore.ts
Normal file
26
src/stores/visualAssetStore.ts
Normal 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
2
src/types/types.ts
Normal 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
13
src/utils.ts
Normal 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
12
tsconfig.app.json
Normal 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
14
tsconfig.json
Normal 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
19
tsconfig.node.json
Normal 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
15
vite.config.ts
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user