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