This commit is contained in:
commit
7f02e92304
3
.browserslistrc
Normal file
3
.browserslistrc
Normal file
@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||
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
|
81
.github/CONTRIBUTING.md
vendored
Normal file
81
.github/CONTRIBUTING.md
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing! Please feel free to put up a PR for any issue, feature request or enhancement.
|
||||
|
||||
## Reporting issues & features requests
|
||||
|
||||
If you notice any bugs in the code, see some code that can be improved, or have features you would like to be added, please create a bug report or a feature request.
|
||||
|
||||
## Working on issues
|
||||
|
||||
Please feel free to take on any issue that's currently open. Just send a comment in order to let us know you're working on it so we can assign that specific issue to you.
|
||||
|
||||
## Submitting a pull request
|
||||
|
||||
Before working on a large change, the best practice is to open an issue first to discuss it with the maintainers or if an issue is already opened, comment your intention of opening up a PR.
|
||||
|
||||
When in doubt, keep your pull requests small. To give a PR the best chance of getting accepted, don't bundle more than one feature or bug fix per pull request. It's always best to create two smaller PRs than one big one.
|
||||
|
||||
### Branch types
|
||||
|
||||
1. **Feature** - New implementation code that is required for product development. Everything that is not considered a defect and brings value is considered a feature. Example: **feature/add-vite**
|
||||
2. **Bug** - Defects, either flagged by the QA team or any of the parties involved in the project, missing functionality or wrongly implemented functionality, they all fall into the “bug” category. Branches that solve such defects should be prefixed with the **bug** prefix. Example: **bug/fix-slots**
|
||||
3. **Chore** — Miscellaneous work not related to the project code. For example, updating node module versions, renaming an environment configuration file or removing unused variables. Example: **chore/rename-config-file**
|
||||
4. **Docs** — Any work that relates to project-level and code-level documentation. Whether it is work related to the project's **README**, or code-level documentation, branches that host this type of work should use this prefix. Example: **docs/add-vite-info**
|
||||
|
||||
### Commit formatting
|
||||
|
||||
Every file changed should have its own commit message and each commit message consists of a **header**, a **body** and a **footer**. The header has a special format that includes a **type**, a **scope** and a **subject**:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
<BLANK LINE>
|
||||
<body>
|
||||
<BLANK LINE>
|
||||
<footer>
|
||||
```
|
||||
|
||||
From the above template, only the **header** is mandatory (_note_: the **scope** of the header is not mandatory) and the **header** should not be longer than 100 characters!
|
||||
|
||||
#### Type
|
||||
|
||||
Can only be one of the following:
|
||||
|
||||
- **build**: Changes that affect the build system or external dependencies (example scopes: vue-cli, vite, npm)
|
||||
- **docs**: Documentation only changes
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **perf**: A code change that improves performance
|
||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
#### Scope
|
||||
|
||||
The scope should be the name of the npm package affected (as perceived by the person reading the changelog generated from commit messages).
|
||||
|
||||
#### Subject
|
||||
|
||||
The subject is a succinct description of what this commit does.
|
||||
|
||||
#### Body
|
||||
|
||||
A large detail, if needed, of what this commit does.
|
||||
|
||||
#### Footer
|
||||
|
||||
The footer should only contain a closing statement for an issue.
|
||||
|
||||
#### Samples:
|
||||
|
||||
```
|
||||
docs(changelog): update changelog to v2.0.0
|
||||
```
|
||||
|
||||
```
|
||||
refactor: change sample data fecth
|
||||
|
||||
Sample data is only fetched once
|
||||
|
||||
Closes #2
|
||||
```
|
33
.github/workflows/build.yml
vendored
Normal file
33
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# This is a basic workflow to help you get started with Actions
|
||||
name: Build
|
||||
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the master branch
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
# This workflow contains a single job called "build"
|
||||
build:
|
||||
# The type of runner that the job will run on
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'dist' folder
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
- name: Deploy 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
branch: gh-pages # The branch the action should deploy to.
|
||||
folder: dist # The folder the action should deploy.
|
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
|
490
.laravel-guide/README.md
Normal file
490
.laravel-guide/README.md
Normal file
@ -0,0 +1,490 @@
|
||||
# Free Laravel Vue 3.x Tailwind 4.x Dashboard
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
This guide will help you integrate your Laravel application with [Admin One - free Vue 3 Tailwind 4 Admin Dashboard with dark mode](https://github.com/justboil/admin-one-vue-tailwind).
|
||||
|
||||
**Admin One** is simple, fast and free Vue.js 3.x Tailwind CSS 4.x admin dashboard with Laravel 12.x integration.
|
||||
|
||||
- Built with **Vue.js 3**, **Tailwind CSS 4** framework & **Composition API**
|
||||
- **Laravel** build tools
|
||||
- **Laravel Jetstream** with **Inertia + Vue** stack
|
||||
- **SFC** `<script setup>` [Info](https://v3.vuejs.org/api/sfc-script-setup.html)
|
||||
- **Pinia** state library (official Vuex 5)
|
||||
- **Dark mode**
|
||||
- **Styled** scrollbars
|
||||
- **Production CSS** is only **≈38kb**
|
||||
- Reusable components
|
||||
- Free under MIT License
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Install](#install)
|
||||
- [Copy styles, components and scripts](#copy-styles-components-and-scripts)
|
||||
- [Add pages](#add-pages)
|
||||
- [Fix router links](#fix-router-links)
|
||||
- [Add Inertia-related stuff](#add-inertia-related-stuff)
|
||||
- [Upgrading to Premium version](#upgrading-to-premium-version)
|
||||
- [Optional steps](#optional-steps)
|
||||
- [Laravel & Inertia docs](#laravel--inertia-docs)
|
||||
|
||||
## Install
|
||||
|
||||
### Install Laravel
|
||||
|
||||
First, [install Laravel](https://laravel.com/docs/installation) application
|
||||
|
||||
### Install Jetstream
|
||||
|
||||
Then `cd` to project dir and install Jetstream with Inertia Vue stack
|
||||
|
||||
```bash
|
||||
composer require laravel/jetstream
|
||||
|
||||
php artisan jetstream:install inertia
|
||||
|
||||
php artisan migrate
|
||||
|
||||
npm install
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
npm i pinia @mdi/js chart.js numeral -D
|
||||
```
|
||||
|
||||
## Copy styles, components and scripts
|
||||
|
||||
**Before you start,** we recommend to rename Laravel Jetsreams's original folders (so you'll keep them for future reference) — `resources/js/Components` `resources/js/Layouts` `resources/js/Pages` to something like ComponentsJetsteam, LayoutsJetstream, etc.
|
||||
|
||||
Now clone [justboil/admin-one-vue-tailwind](https://github.com/justboil/admin-one-vue-tailwind) project somewhere locally (into any separate folder)
|
||||
|
||||
Next, copy these files **from justboil/admin-one-vue-tailwind project** directory **to laravel project** directory:
|
||||
|
||||
- Copy `tailwind.config.js` to `/`
|
||||
- Copy `src/components` `src/layouts` `src/stores` `src/colors.js` `src/config.js` `src/menuAside.js` `src/menuNavBar.js` to `resources/js/`
|
||||
- Copy `.laravel-guide/resources/js/` to `resources/js/`
|
||||
- Delete `resources/css/app.css`
|
||||
- Copy `src/css` to `resources/css`
|
||||
|
||||
### [optional] lowecase vs Capitalized folder names
|
||||
|
||||
Fresh Laravel install with Jetstream provides **Capitalized** folder names such as `Components`, `Layouts`, etc. For the sake of simplicity we just follow Vue conventions with lowercase folder names. However, you may opt-in to capitalize folder names:
|
||||
|
||||
- Make sure you've removed original Laravel Jetstream's `resources/js/Layouts` and `resources/js/Components` folders
|
||||
- Rename the folders you've copied in the previous section: `resources/js/layouts` to `Layouts`; `components` to `Components`; `stores` to `Stores`
|
||||
- Replace everywhere in imports: `@/layouts/` with `@/Layouts/`; `@/components/` with `@/Components/`; `@/stores/` with `@/Stores/`
|
||||
|
||||
### In tailwind.config.js
|
||||
|
||||
Please make sure, you've copied template's `tailwind.config.js`. Then replace `content`, to reflect Laravel's structure:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
|
||||
'./storage/framework/views/*.php',
|
||||
'./resources/views/**/*.blade.php',
|
||||
'./resources/js/**/*.vue',
|
||||
'./resources/js/**/*.js'
|
||||
]
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### In resources/views/app.blade.php
|
||||
|
||||
- Remove `<link rel="stylesheet" href="https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap">`
|
||||
|
||||
## Add Pages
|
||||
|
||||
Let's just add first page. You can repeat these steps for other pages, if you wish to.
|
||||
|
||||
First, copy `src/views/HomeView.vue` (from justboil/admin-one-vue-tailwind project) to `resources/js/Pages/` (in your Laravel project).
|
||||
|
||||
Then, open `resources/js/Pages/HomeView.vue` and add `<Head>`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3'
|
||||
// ...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutAuthenticated>
|
||||
<Head title="Dashboard" />
|
||||
<!-- ... -->
|
||||
</LayoutAuthenticated>
|
||||
</template>
|
||||
```
|
||||
|
||||
Add route in `routes/web.php`. There's a `/dashboard` route already defined by default, so just replace `Inertia::render('Dashboard')` with `Inertia::render('HomeView')`:
|
||||
|
||||
```php
|
||||
Route::get('/dashboard', function () {
|
||||
return Inertia::render('HomeView');
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
```
|
||||
|
||||
## Fix router links
|
||||
|
||||
Here we replace RouterLink with Inertia Link.
|
||||
|
||||
### resources/js/menuAside.js and resources/js/menuNavBar.js
|
||||
|
||||
Optionally, you can pass menu via Inertia shared props, so you'll be able to control it with PHP. Here we'd just use JS.
|
||||
|
||||
`to` should be replaced with `route` which specifies route name defined in `routes/web.php`. For external links `href` should be used instead. Here's an example for `menuAside.js`:
|
||||
|
||||
```javascript
|
||||
export default [
|
||||
'General',
|
||||
[
|
||||
{
|
||||
route: 'dashboard',
|
||||
icon: mdiMonitor,
|
||||
label: 'Dashboard'
|
||||
},
|
||||
// {
|
||||
// route: "another-route-name",
|
||||
// icon: mdiMonitor,
|
||||
// label: "Dashboard 2",
|
||||
// },
|
||||
{
|
||||
href: 'https://example.com/',
|
||||
icon: mdiMonitor,
|
||||
label: 'Example.com'
|
||||
}
|
||||
]
|
||||
]
|
||||
```
|
||||
|
||||
Route names reflect ones defined in `routes/web.php`:
|
||||
|
||||
```php
|
||||
Route::middleware(['auth:sanctum', 'verified'])->get('/dashboard', function () {
|
||||
return Inertia::render('Home');
|
||||
})->name('dashboard');
|
||||
|
||||
// Route::middleware(['auth:sanctum', 'verified'])->get('/dashboard-2', function () {
|
||||
// return Inertia::render('Home2');
|
||||
// })->name('another-route-name');
|
||||
```
|
||||
|
||||
Now, let's update vue files, to make them work with route names and Inertia links.
|
||||
|
||||
### resources/js/components/AsideMenuItem.vue
|
||||
|
||||
Replace `RouterLink` imported from `vue-router` with `Link` import in `<script setup>` and add consts:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
// import { RouterLink } from "vue-router";
|
||||
// ...
|
||||
|
||||
// Add itemHref
|
||||
const itemHref = computed(() => (props.item.route ? route(props.item.route) : props.item.href))
|
||||
|
||||
// Add activeInactiveStyle
|
||||
const activeInactiveStyle = computed(() =>
|
||||
props.item.route && route().current(props.item.route)
|
||||
? darkModeStore.asideMenuItemActiveStyle
|
||||
: ''
|
||||
)
|
||||
|
||||
// ...
|
||||
</script>
|
||||
```
|
||||
|
||||
In `<template>` section:
|
||||
|
||||
- In `<component>` remove `v-slot` and `:to` attributes; replace `:is` with `:is="item.route ? Link : 'a'"` and `:href` with `:href="itemHref"`
|
||||
- Inside `<component>` replace `:class` attribute for both `<BaseIcon>` components, `<span>` and another `<BaseIcon>` with `:class="activeInactiveStyle"`
|
||||
- ...and for `<span>` (also inside `<component>`) replace `:class` attribute with `:class="[{ 'pr-12': !hasDropdown }, activeInactiveStyle]"`
|
||||
|
||||
### resources/js/components/BaseButton.vue
|
||||
|
||||
Replace `RouterLink` imported from `vue-router` with `Link` import in `<script setup>`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
// import { RouterLink } from "vue-router";
|
||||
// ...
|
||||
</script>
|
||||
```
|
||||
|
||||
Replace `to` prop declaration with `routeName`:
|
||||
|
||||
```javascript
|
||||
const props = defineProps({
|
||||
// ...
|
||||
routeName: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
Fix `const is` declaration, so it returns the `Link` component when `props.routeName` is set:
|
||||
|
||||
```javascript
|
||||
const is = computed(() => {
|
||||
if (props.as) {
|
||||
return props.as
|
||||
}
|
||||
|
||||
if (props.routeName) {
|
||||
return Link
|
||||
}
|
||||
|
||||
if (props.href) {
|
||||
return 'a'
|
||||
}
|
||||
|
||||
return 'button'
|
||||
})
|
||||
```
|
||||
|
||||
Remove `:to` and replace `:href` in `<component>` with `:href="routeName ? route(routeName) : href"`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<component
|
||||
:is="is"
|
||||
:class="componentClass"
|
||||
:href="routeName ? route(routeName) : href"
|
||||
:type="computedType"
|
||||
:target="target"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<!-- ... -->
|
||||
</component>
|
||||
</template>
|
||||
```
|
||||
|
||||
### resources/js/components/NavBarItem.vue
|
||||
|
||||
Replace `RouterLink` imported from `vue-router` with `Link` import in `<script setup>`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
// import { RouterLink } from "vue-router";
|
||||
// ...
|
||||
|
||||
// Add itemHref
|
||||
const itemHref = computed(() => (props.item.route ? route(props.item.route) : props.item.href))
|
||||
|
||||
// Update `const is` to return `Link` when `props.routeName` is set:
|
||||
const is = computed(() => {
|
||||
if (props.item.href) {
|
||||
return 'a'
|
||||
}
|
||||
|
||||
if (props.item.route) {
|
||||
return Link
|
||||
}
|
||||
|
||||
return 'div'
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
Then, remove `:to` attribute and set `:href` attribute to `:href="itemHref"` in `<component>`.
|
||||
|
||||
## Add Inertia-related stuff
|
||||
|
||||
### resources/js/layouts/LayoutAuthenticated.vue
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Remove vue-router stuff:
|
||||
|
||||
// import { useRouter } from 'vue-router'
|
||||
|
||||
// const router = useRouter()
|
||||
|
||||
// router.beforeEach(() => {
|
||||
// isAsideMobileExpanded.value = false
|
||||
// isAsideLgActive.value = false
|
||||
// })
|
||||
|
||||
// Add:
|
||||
|
||||
import { router } from '@inertiajs/vue3'
|
||||
|
||||
router.on('navigate', () => {
|
||||
isAsideMobileExpanded.value = false
|
||||
isAsideLgActive.value = false
|
||||
})
|
||||
|
||||
// Replace `isLogout` logic:
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
// ...
|
||||
|
||||
if (item.isLogout) {
|
||||
// Add:
|
||||
router.post(route('logout'))
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
</script>
|
||||
```
|
||||
|
||||
### resources/js/components/UserAvatarCurrentUser.vue
|
||||
|
||||
Let's fetch user avatar initials based on username stored in database.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const userName = computed(() => usePage().props.auth.user.name)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar :username="userName" api="initials">
|
||||
<slot />
|
||||
</UserAvatar>
|
||||
</template>
|
||||
```
|
||||
|
||||
### resources/js/components/NavBarItem.vue
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Add usePage:
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
// Remove unused useMainStore:
|
||||
// import { useMainStore } from '@/stores/main.js'
|
||||
// ...
|
||||
|
||||
// Update itemLabel:
|
||||
const itemLabel = computed(() =>
|
||||
props.item.isCurrentUser ? usePage().props.auth.user.name : props.item.label
|
||||
)
|
||||
|
||||
// ...
|
||||
</script>
|
||||
```
|
||||
|
||||
## Upgrading to Premium version
|
||||
|
||||
Please make sure you have completed all previous steps in this guide, so you have everything up and running. Then download and uzip the Premium version files. Next, follow steps described below.
|
||||
|
||||
### Add deps
|
||||
|
||||
```bash
|
||||
npm i @headlessui/vue -D
|
||||
```
|
||||
|
||||
### Copy files
|
||||
|
||||
Copy files to your Laravel project (overwrite free version ones or merge if you have changed something):
|
||||
|
||||
- Copy `src/components/Premium` to `resources/js/components/Premium`
|
||||
- Copy `src/stores` to `resources/js/stores`
|
||||
- Copy `src/config.js` to `resources/js/config.js`
|
||||
- Copy `src/sampleButtonMenuOptions.js` to `resources/js/sampleButtonMenuOptions.js`
|
||||
- Copy `src/colorsPremium.js` to `resources/js/colorsPremium.js`
|
||||
|
||||
### Update tailwind.config.js
|
||||
|
||||
Replace `tailwind.config.js` in your Laravel project with the Premium one. Make sure to preserve `module.exports.content`:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
|
||||
'./storage/framework/views/*.php',
|
||||
'./resources/views/**/*.blade.php',
|
||||
'./resources/js/**/*.vue',
|
||||
'./resources/js/**/*.js'
|
||||
]
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Update resources/js/app.js
|
||||
|
||||
Add layout store to `resources/js/app.js`:
|
||||
|
||||
```js
|
||||
// Add layout store
|
||||
import { useLayoutStore } from '@/stores/layout.js'
|
||||
|
||||
const layoutStore = useLayoutStore(pinia)
|
||||
|
||||
layoutStore.responsiveLayoutControl()
|
||||
window.onresize = () => layoutStore.responsiveLayoutControl()
|
||||
```
|
||||
|
||||
### Update resources/js/layouts/LayoutAuthenticated.vue
|
||||
|
||||
Replace contents of `resources/js/layouts/LayoutAuthenticated.vue` with contents of `src/js/layouts/LayoutAuthenticated.vue` (from the Premium version)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// Replace router use:
|
||||
|
||||
// import { useRouter } from "vue-router";
|
||||
import { router } from '@inertiajs/vue3'
|
||||
|
||||
// const router = useRouter();
|
||||
|
||||
// router.beforeEach(() => {
|
||||
// layoutStore.isAsideMobileExpanded = false;
|
||||
// });
|
||||
|
||||
router.on('navigate', () => {
|
||||
layoutStore.isAsideMobileExpanded = false
|
||||
})
|
||||
|
||||
// Add logout:
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
// ...
|
||||
|
||||
if (item.isLogout) {
|
||||
router.post(route('logout'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Use premium login and signup layouts
|
||||
|
||||
Optionally, you may update layouts of login and signup, located at `resources/js/Pages/Auth` with Premium version layouts from `src/views/Premium/LoginView.vue` and `src/views/Premium/SignupView.vue`
|
||||
|
||||
## Optional steps
|
||||
|
||||
### Default style
|
||||
|
||||
It's likely, you'll use only one app style. Follow [this guide](https://justboil.github.io/docs/customization/#default-style) to set one of choice.
|
||||
|
||||
### Fix .editorconfig
|
||||
|
||||
Add to .editorconfig:
|
||||
|
||||
```editorconfig
|
||||
[*.{js,jsx,ts,tsx,vue,html,css}]
|
||||
indent_size = 2
|
||||
```
|
||||
|
||||
### resources/js/bootstrap.js
|
||||
|
||||
Global `lodash` and `axios` aren't needed, as we import them directly when necessary.
|
||||
|
||||
## Laravel & Inertia docs
|
||||
|
||||
- [Laravel Docs](https://laravel.com/docs)
|
||||
- [Laravel Jetstream Docs](https://jetstream.laravel.com/)
|
||||
- [Inertia](https://inertiajs.com/)
|
78
.laravel-guide/resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
78
.laravel-guide/resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { useForm, Head } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
import SectionFullScreen from '@/components/SectionFullScreen.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import FormValidationErrors from '@/components/FormValidationErrors.vue'
|
||||
|
||||
const form = useForm({
|
||||
password: ''
|
||||
})
|
||||
|
||||
const passwordInput = ref(null)
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('password.confirm'), {
|
||||
onFinish: () => {
|
||||
form.reset()
|
||||
|
||||
passwordInput.value?.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Secure Area" />
|
||||
|
||||
<SectionFullScreen
|
||||
v-slot="{ cardClass }"
|
||||
bg="purplePink"
|
||||
>
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<FormField>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
This is a secure area of the application. Please confirm your password before continuing.
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
label-for="password"
|
||||
help="Please enter your password to confirm"
|
||||
>
|
||||
<FormControl
|
||||
id="password"
|
||||
@set-ref="passwordInput = $event"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Confirm"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
91
.laravel-guide/resources/js/Pages/Auth/ForgotPassword.vue
Normal file
91
.laravel-guide/resources/js/Pages/Auth/ForgotPassword.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { useForm, Head, Link } from '@inertiajs/vue3'
|
||||
import { mdiEmail } from '@mdi/js'
|
||||
import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
import SectionFullScreen from '@/components/SectionFullScreen.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import FormValidationErrors from '@/components/FormValidationErrors.vue'
|
||||
import NotificationBarInCard from '@/components/NotificationBarInCard.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
|
||||
defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
email: ''
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('password.email'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Forgot Password" />
|
||||
|
||||
<SectionFullScreen
|
||||
v-slot="{ cardClass }"
|
||||
bg="purplePink"
|
||||
>
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<NotificationBarInCard
|
||||
v-if="status"
|
||||
color="info"
|
||||
>
|
||||
{{ status }}
|
||||
</NotificationBarInCard>
|
||||
|
||||
<FormField>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
help="Please enter your email"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.email"
|
||||
:icon="mdiEmail"
|
||||
autocomplete="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseLevel>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Email link"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<Link
|
||||
:href="route('login')"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
129
.laravel-guide/resources/js/Pages/Auth/Login.vue
Normal file
129
.laravel-guide/resources/js/Pages/Auth/Login.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { useForm, Head, Link } from '@inertiajs/vue3'
|
||||
import { mdiAccount, mdiAsterisk } from '@mdi/js'
|
||||
import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
import SectionFullScreen from '@/components/SectionFullScreen.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormCheckRadioGroup from '@/components/FormCheckRadioGroup.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseButtons from '@/components/BaseButtons.vue'
|
||||
import FormValidationErrors from '@/components/FormValidationErrors.vue'
|
||||
import NotificationBarInCard from '@/components/NotificationBarInCard.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
canResetPassword: Boolean,
|
||||
status: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: []
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form
|
||||
.transform(data => ({
|
||||
... data,
|
||||
remember: form.remember && form.remember.length ? 'on' : ''
|
||||
}))
|
||||
.post(route('login'), {
|
||||
onFinish: () => form.reset('password'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Login" />
|
||||
|
||||
<SectionFullScreen
|
||||
v-slot="{ cardClass }"
|
||||
bg="purplePink"
|
||||
>
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<NotificationBarInCard
|
||||
v-if="status"
|
||||
color="info"
|
||||
>
|
||||
{{ status }}
|
||||
</NotificationBarInCard>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
label-for="email"
|
||||
help="Please enter your email"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.email"
|
||||
:icon="mdiAccount"
|
||||
id="email"
|
||||
autocomplete="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
label-for="password"
|
||||
help="Please enter your password"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.password"
|
||||
:icon="mdiAsterisk"
|
||||
type="password"
|
||||
id="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormCheckRadioGroup
|
||||
v-model="form.remember"
|
||||
name="remember"
|
||||
:options="{ remember: 'Remember' }"
|
||||
/>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseLevel>
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Login"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="canResetPassword"
|
||||
route-name="password.request"
|
||||
color="info"
|
||||
outline
|
||||
label="Remind"
|
||||
/>
|
||||
</BaseButtons>
|
||||
<Link
|
||||
:href="route('register')"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
131
.laravel-guide/resources/js/Pages/Auth/Register.vue
Normal file
131
.laravel-guide/resources/js/Pages/Auth/Register.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { useForm, usePage, Head } from "@inertiajs/vue3";
|
||||
import { computed } from "vue";
|
||||
import { mdiAccount, mdiEmail, mdiFormTextboxPassword } from "@mdi/js";
|
||||
import LayoutGuest from "@/layouts/LayoutGuest.vue";
|
||||
import SectionFullScreen from "@/components/SectionFullScreen.vue";
|
||||
import CardBox from "@/components/CardBox.vue";
|
||||
import FormCheckRadioGroup from "@/components/FormCheckRadioGroup.vue";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import FormControl from "@/components/FormControl.vue";
|
||||
import BaseDivider from "@/components/BaseDivider.vue";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import BaseButtons from "@/components/BaseButtons.vue";
|
||||
import FormValidationErrors from "@/components/FormValidationErrors.vue";
|
||||
|
||||
const form = useForm({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
password_confirmation: "",
|
||||
terms: [],
|
||||
});
|
||||
|
||||
const hasTermsAndPrivacyPolicyFeature = computed(
|
||||
() => usePage().props.jetstream?.hasTermsAndPrivacyPolicyFeature
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
terms: form.terms && form.terms.length,
|
||||
}))
|
||||
.post(route("register"), {
|
||||
onFinish: () => form.reset("password", "password_confirmation"),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Register" />
|
||||
|
||||
<SectionFullScreen v-slot="{ cardClass }" bg="purplePink">
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
class="my-24"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<FormField label="Name" label-for="name" help="Please enter your name">
|
||||
<FormControl
|
||||
v-model="form.name"
|
||||
id="name"
|
||||
:icon="mdiAccount"
|
||||
autocomplete="name"
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
label-for="email"
|
||||
help="Please enter your email"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.email"
|
||||
id="email"
|
||||
:icon="mdiEmail"
|
||||
autocomplete="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
label-for="password"
|
||||
help="Please enter new password"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
:icon="mdiFormTextboxPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
label-for="password_confirmation"
|
||||
help="Please confirm your password"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.password_confirmation"
|
||||
id="password_confirmation"
|
||||
:icon="mdiFormTextboxPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormCheckRadioGroup
|
||||
v-if="hasTermsAndPrivacyPolicyFeature"
|
||||
v-model="form.terms"
|
||||
name="remember"
|
||||
:options="{ agree: 'I agree to the Terms' }"
|
||||
/>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Register"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<BaseButton route-name="login" color="info" outline label="Login" />
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
111
.laravel-guide/resources/js/Pages/Auth/ResetPassword.vue
Normal file
111
.laravel-guide/resources/js/Pages/Auth/ResetPassword.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { useForm, Head, Link } from '@inertiajs/vue3'
|
||||
import { mdiEmail, mdiFormTextboxPassword } from '@mdi/js'
|
||||
import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
import SectionFullScreen from '@/components/SectionFullScreen.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import FormValidationErrors from '@/components/FormValidationErrors.vue'
|
||||
|
||||
const props = defineProps({
|
||||
email: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
token: props.token,
|
||||
email: props.email,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
form
|
||||
.post(route('password.update'), {
|
||||
onFinish: () => form.reset('password', 'password_confirmation'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Reset Password" />
|
||||
|
||||
<SectionFullScreen
|
||||
v-slot="{ cardClass }"
|
||||
bg="purplePink"
|
||||
>
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
label-for="email"
|
||||
help="Please enter your email"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.email"
|
||||
:icon="mdiEmail"
|
||||
autocomplete="email"
|
||||
type="email"
|
||||
id="email"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Password"
|
||||
label-for="password"
|
||||
help="Please enter new password"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.password"
|
||||
:icon="mdiFormTextboxPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id="password"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm Password"
|
||||
label-for="password_confirmation"
|
||||
help="Please confirm new password"
|
||||
>
|
||||
<FormControl
|
||||
v-model="form.password_confirmation"
|
||||
:icon="mdiFormTextboxPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
id="password_confirmation"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Reset password"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
126
.laravel-guide/resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
126
.laravel-guide/resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { useForm, Head } from '@inertiajs/vue3'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
import SectionFullScreen from '@/components/SectionFullScreen.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import FormValidationErrors from '@/components/FormValidationErrors.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
|
||||
const recovery = ref(false)
|
||||
|
||||
const form = useForm({
|
||||
code: '',
|
||||
recovery_code: ''
|
||||
})
|
||||
|
||||
const recoveryCodeInput = ref(null)
|
||||
const codeInput = ref(null)
|
||||
|
||||
const toggleRecovery = async () => {
|
||||
recovery.value ^= true
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (recovery.value) {
|
||||
recoveryCodeInput.value?.focus()
|
||||
form.code = ''
|
||||
} else {
|
||||
codeInput.value?.focus()
|
||||
form.recovery_code = ''
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
form.post(route('two-factor.login'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Two-factor Confirmation" />
|
||||
|
||||
<SectionFullScreen
|
||||
v-slot="{ cardClass }"
|
||||
bg="purplePink"
|
||||
>
|
||||
<CardBox
|
||||
:class="cardClass"
|
||||
is-form
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormValidationErrors />
|
||||
|
||||
<FormField>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
<template v-if="! recovery">
|
||||
Please confirm access to your account by entering the authentication code provided by your authenticator application.
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
Please confirm access to your account by entering one of your emergency recovery codes.
|
||||
</template>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="!recovery"
|
||||
label="Code"
|
||||
label-for="code"
|
||||
help="Please enter one-time code"
|
||||
>
|
||||
<FormControl
|
||||
id="code"
|
||||
@set-ref="codeInput = $event"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-else
|
||||
label="Recovery Code"
|
||||
label-for="recovery_code"
|
||||
help="Please enter recovery code"
|
||||
>
|
||||
<FormControl
|
||||
id="recovery_code"
|
||||
@set-ref="recoveryCodeInput = $event"
|
||||
v-model="form.recovery_code"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseLevel>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Log in"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<button @click.prevent="toggleRecovery">
|
||||
<template v-if="!recovery">
|
||||
Use a recovery code
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
Use an authentication code
|
||||
</template>
|
||||
</button>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
70
.laravel-guide/resources/js/Pages/Auth/VerifyEmail.vue
Normal file
70
.laravel-guide/resources/js/Pages/Auth/VerifyEmail.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { useForm, Head, Link } from "@inertiajs/vue3";
|
||||
import { computed } from "vue";
|
||||
import LayoutGuest from "@/layouts/LayoutGuest.vue";
|
||||
import SectionFullScreen from "@/components/SectionFullScreen.vue";
|
||||
import CardBox from "@/components/CardBox.vue";
|
||||
import FormField from "@/components/FormField.vue";
|
||||
import BaseDivider from "@/components/BaseDivider.vue";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import FormValidationErrors from "@/components/FormValidationErrors.vue";
|
||||
import NotificationBarInCard from "@/components/NotificationBarInCard.vue";
|
||||
import BaseLevel from "@/components/BaseLevel.vue";
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm();
|
||||
|
||||
const verificationLinkSent = computed(
|
||||
() => props.status === "verification-link-sent"
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
form.post(route("verification.send"));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGuest>
|
||||
<Head title="Email Verification" />
|
||||
|
||||
<SectionFullScreen v-slot="{ cardClass }" bg="purplePink">
|
||||
<CardBox :class="cardClass" is-form @submit.prevent="submit">
|
||||
<FormValidationErrors />
|
||||
|
||||
<NotificationBarInCard v-if="verificationLinkSent" color="info">
|
||||
A new verification link has been sent to the email address you
|
||||
provided during registration.
|
||||
</NotificationBarInCard>
|
||||
|
||||
<FormField>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
Thanks for signing up! Before getting started, could you verify your
|
||||
email address by clicking on the link we just emailed to you? If you
|
||||
didn't receive the email, we will gladly send you another.
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseLevel>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label="Resend Verification Email"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<Link :href="route('logout')" method="post" as="button">
|
||||
Logout
|
||||
</Link>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</SectionFullScreen>
|
||||
</LayoutGuest>
|
||||
</template>
|
37
.laravel-guide/resources/js/app.js
Normal file
37
.laravel-guide/resources/js/app.js
Normal file
@ -0,0 +1,37 @@
|
||||
import '../css/main.css'
|
||||
|
||||
import { createPinia } from 'pinia'
|
||||
// import { useDarkModeStore } from '@/stores/darkMode.js'
|
||||
import { createApp, h } from 'vue'
|
||||
import { createInertiaApp } from '@inertiajs/vue3'
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
|
||||
import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m'
|
||||
|
||||
const appName = window.document.getElementsByTagName('title')[0]?.innerText || 'Laravel'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => `${title} - ${appName}`,
|
||||
resolve: (name) =>
|
||||
resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
|
||||
setup({ el, App, props, plugin }) {
|
||||
return createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(pinia)
|
||||
.use(ZiggyVue, Ziggy)
|
||||
.mount(el)
|
||||
},
|
||||
progress: {
|
||||
color: '#4B5563'
|
||||
}
|
||||
})
|
||||
|
||||
// const darkModeStore = useDarkModeStore(pinia)
|
||||
|
||||
// if (
|
||||
// (!localStorage['darkMode'] && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
// localStorage['darkMode'] === '1'
|
||||
// ) {
|
||||
// darkModeStore.set(true)
|
||||
// }
|
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
import NotificationBarInCard from "@/components/NotificationBarInCard.vue";
|
||||
|
||||
const errors = computed(() => usePage().props.errors);
|
||||
|
||||
const hasErrors = computed(() => Object.keys(errors.value).length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NotificationBarInCard v-if="hasErrors" color="danger">
|
||||
<b>Whoops! Something went wrong.</b>
|
||||
<span v-for="(error, key) in errors" :key="key">{{ error }}</span>
|
||||
</NotificationBarInCard>
|
||||
</template>
|
291
.nuxt-guide/README.md
Normal file
291
.nuxt-guide/README.md
Normal file
@ -0,0 +1,291 @@
|
||||
# Free Nuxt 3.x Vue 3.x Tailwind 4.x Dashboard
|
||||
|
||||
This guide will help you integrate your Nuxt.js 3.x application with [Admin One - free Vue 3 Tailwind 4 Admin Dashboard with dark mode](https://github.com/justboil/admin-one-vue-tailwind).
|
||||
|
||||
**Please note:** this document is work in progress and Nuxt 3 is in Release Candidate state, so some things can be missing and warnings may occur.
|
||||
|
||||
## Table of contents
|
||||
|
||||
... TOC is coming soon
|
||||
|
||||
## Install Nuxt.js 3.x app with Tailwind CSS
|
||||
|
||||
Check [Nuxt installation guide](https://v3.nuxtjs.org/getting-started/quick-start) for more information.
|
||||
|
||||
- Run `npx nuxi init sample-app`
|
||||
- then `cd sample-app`
|
||||
- and `npm install`
|
||||
|
||||
Then, let's install TailwindCSS. Check [Tailwind Nuxt installation guide](https://tailwindcss.com/docs/guides/nuxtjs) for more information.
|
||||
|
||||
```sh
|
||||
# Install tailwind
|
||||
npm install -D @nuxtjs/tailwindcss @tailwindcss/forms
|
||||
|
||||
# Install other required packages
|
||||
npm i @mdi/js chart.js numeral
|
||||
|
||||
# Install Pinia (official Vuex 5). --legacy-peer-deps is required because of the package dependencies issue
|
||||
npm install --legacy-peer-deps pinia @pinia/nuxt
|
||||
```
|
||||
|
||||
### Copy styles, components and scripts
|
||||
|
||||
Now clone [justboil/admin-one-vue-tailwind](https://github.com/justboil/admin-one-vue-tailwind) project somewhere locally (into any separate folder)
|
||||
|
||||
Next, copy these files **from justboil/admin-one-vue-tailwind project** directory **to nuxt project** directory:
|
||||
|
||||
- Copy `tailwind.config.js` to `/`
|
||||
- Copy `src/components` to `components/`
|
||||
- Copy `src/layouts` to `layouts/`
|
||||
- Copy `src/stores` to `stores/`
|
||||
- Copy `src/colors.js` `src/config.js` `src/menuAside.js` `src/menuNavBar.js` to `configs/`
|
||||
- Copy `src/css` to `assets/css/`
|
||||
- Copy `public/favicon.png` to `public/`
|
||||
|
||||
### Prepare items
|
||||
|
||||
#### In nuxt.config.ts
|
||||
|
||||
```javascript
|
||||
import { defineNuxtConfig } from 'nuxt'
|
||||
|
||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||
export default defineNuxtConfig({
|
||||
buildModules: ['@pinia/nuxt'],
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
},
|
||||
css: ['@/assets/css/main.css']
|
||||
})
|
||||
```
|
||||
|
||||
#### In tailwind.config.js
|
||||
|
||||
Replace `content`:
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
content: [
|
||||
'./composables/**/*.{js,vue,ts}',
|
||||
'./components/**/*.{js,vue,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'app.vue'
|
||||
]
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### In App.vue
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// import { useDarkModeStore } from '@/stores/darkMode.js'
|
||||
// import { darkModeKey } from '@/config.js'
|
||||
|
||||
useHead({
|
||||
titleTemplate: (titleChunk) => {
|
||||
const titleBase = 'Admin One Vue 3 Tailwind'
|
||||
|
||||
return titleChunk ? `${titleChunk} - ${titleBase}` : titleBase
|
||||
}
|
||||
})
|
||||
|
||||
// const darkModeStore = useDarkModeStore()
|
||||
|
||||
// const currentStoredDarkMode =
|
||||
// typeof localStorage !== 'undefined' && localStorage[darkModeKey] === '1'
|
||||
|
||||
// if (
|
||||
// (!currentStoredDarkMode &&
|
||||
// typeof window !== 'undefined' &&
|
||||
// window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
// currentStoredDarkMode
|
||||
// ) {
|
||||
// darkModeStore.set(true)
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### In stores/main.js
|
||||
|
||||
Remove `axios`, as you'll likely going to use Nuxt's `useFetch`. Then add some sample data for `clients` and `history`.
|
||||
|
||||
```javascript
|
||||
// import axios from 'axios'
|
||||
|
||||
export const useMainStore = defineStore('main', {
|
||||
state: () => ({
|
||||
// ...
|
||||
|
||||
clients: [
|
||||
{
|
||||
id: 19,
|
||||
avatar: 'https://avatars.dicebear.com/v2/gridy/Howell-Hand.svg',
|
||||
login: 'percy64',
|
||||
name: 'Howell Hand',
|
||||
company: 'Kiehn-Green',
|
||||
city: 'Emelyside',
|
||||
progress: 70,
|
||||
created: 'Mar 3, 2021'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
avatar: 'https://avatars.dicebear.com/v2/gridy/Hope-Howe.svg',
|
||||
login: 'dare.concepcion',
|
||||
name: 'Hope Howe',
|
||||
company: 'Nolan Inc',
|
||||
city: 'Paristown',
|
||||
progress: 68,
|
||||
created: 'Dec 1, 2021'
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
avatar: 'https://avatars.dicebear.com/v2/gridy/Nelson-Jerde.svg',
|
||||
login: 'geovanni.kessler',
|
||||
name: 'Nelson Jerde',
|
||||
company: 'Nitzsche LLC',
|
||||
city: 'Jailynbury',
|
||||
progress: 49,
|
||||
created: 'May 18, 2021'
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
avatar: 'https://avatars.dicebear.com/v2/gridy/Kim-Weimann.svg',
|
||||
login: 'macejkovic.dashawn',
|
||||
name: 'Kim Weimann',
|
||||
company: 'Brown-Lueilwitz',
|
||||
city: 'New Emie',
|
||||
progress: 38,
|
||||
created: 'May 4, 2021'
|
||||
}
|
||||
],
|
||||
history: [
|
||||
{
|
||||
amount: 375.53,
|
||||
name: 'Home Loan Account',
|
||||
date: '3 days ago',
|
||||
type: 'deposit',
|
||||
business: 'Turcotte'
|
||||
},
|
||||
{
|
||||
amount: 470.26,
|
||||
name: 'Savings Account',
|
||||
date: '3 days ago',
|
||||
type: 'payment',
|
||||
business: 'Murazik - Graham'
|
||||
},
|
||||
{
|
||||
amount: 971.34,
|
||||
name: 'Checking Account',
|
||||
date: '5 days ago',
|
||||
type: 'invoice',
|
||||
business: 'Fahey - Keebler'
|
||||
},
|
||||
{
|
||||
amount: 374.63,
|
||||
name: 'Auto Loan Account',
|
||||
date: '7 days ago',
|
||||
type: 'withdrawal',
|
||||
business: 'Collier - Hintz'
|
||||
}
|
||||
]
|
||||
}),
|
||||
actions: {
|
||||
// ...
|
||||
|
||||
fetch(sampleDataKey) {
|
||||
// axios
|
||||
// .get(`data-sources/${sampleDataKey}.json`)
|
||||
// .then(r => {
|
||||
// if (r.data && r.data.data) {
|
||||
// this[sampleDataKey] = r.data.data
|
||||
// }
|
||||
// })
|
||||
// .catch(error => {
|
||||
// alert(error.message)
|
||||
// })
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Rename layouts
|
||||
|
||||
- Rename `layouts/LayoutGuest.vue` to `default.vue`
|
||||
- Rename `layouts/LayoutAuthenticated.vue` to `authenticated.vue`
|
||||
|
||||
## Copy pages
|
||||
|
||||
Let's copy `views/LoginView.vue` with a guest layout and `views/HomeView.vue` with an authenticated layout.
|
||||
|
||||
These pages will then be available under `/` and `/dashboard` url paths.
|
||||
|
||||
#### LoginView.vue
|
||||
|
||||
Copy `views/LoginView.vue` to `pages/index.vue`
|
||||
|
||||
Then, wrap the entire template with `<div>` and replace `<LayoutGuest>` with `<NuxtLayout>`
|
||||
|
||||
**Why we need a div wrapper?** If you use `<NuxtLayout>` within your pages, make sure it is not the root element (or disable layout/page transitions) — [Info](https://v3.nuxtjs.org/guide/directory-structure/layouts#overriding-a-layout-on-a-per-page-basis)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// import LayoutGuest from '@/layouts/LayoutGuest.vue'
|
||||
// ...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<!-- ... -->
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### HomeView.vue
|
||||
|
||||
Copy `views/HomeView.vue` to `pages/dashboard.vue`
|
||||
|
||||
Then, wrap the entire template with `<div>` and replace `<LayoutGuest>` with `<NuxtLayout>` with a `name` prop.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// import LayoutAuthenticated from '@/layouts/LayoutAuthenticated.vue'
|
||||
// ...
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout name="authenticated">
|
||||
<!-- ... -->
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Replace `<RouterLink>` with `<NuxtLink>`
|
||||
|
||||
Details are coming soon...
|
||||
|
||||
## Remove/update imports
|
||||
|
||||
Nuxt automatically imports any components in your `components/` directory. So, you may safely remove that imports from `<script setup>`. The only exception is for `components/Charts`, so charts import should be left as is.
|
||||
|
||||
## Contributions open
|
||||
|
||||
WIP - work in progress. Some things are missing. Contributions open.
|
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"
|
||||
]
|
||||
}
|
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2019-current JustBoil.me (https://justboil.me)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
181
README.md
Normal file
181
README.md
Normal file
@ -0,0 +1,181 @@
|
||||
# [Admin One — Free Vue 3.x Tailwind 4.x Admin Dashboard with dark mode](https://justboil.me/tailwind-admin-templates/free-vue-dashboard/)
|
||||
|
||||
[](https://justboil.me/tailwind-admin-templates/free-vue-dashboard/) [](https://justboil.me/tailwind-admin-templates/free-vue-dashboard/)
|
||||
|
||||
### Tailwind 4.x Vue 3.x with Vite or Nuxt or Laravel
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
### Tailwind 4.x Vue 3.x with Vite or Nuxt or Laravel
|
||||
|
||||
**Admin One** is simple, beautiful and free Vue.js 3.x Tailwind CSS 4.x admin dashboard. Nuxt 3.x or Laravel 12.x integrations available
|
||||
|
||||
* Built with **Vue.js 3**, **Tailwind CSS 4** framework & **Composition API**
|
||||
* **Vite** under the hood — [Info](https://vitejs.dev)
|
||||
* **Nuxt 3** integration available — [Info](#nuxt-3-integration)
|
||||
* **Laravel Breeze Inertia Vue** integration available — [Info](#laravel-9x-integration)
|
||||
* **SFC** `<script setup>` — [Info](https://v3.vuejs.org/api/sfc-script-setup.html)
|
||||
* **Pinia** state library (official Vuex 5) — [Info](https://pinia.vuejs.org/)
|
||||
* **Dark mode**
|
||||
* **Styled** scrollbars
|
||||
* SPA with **Router**
|
||||
* **Production CSS** is only **≈38kb**
|
||||
* Reusable components
|
||||
* Free under MIT License
|
||||
* [Premium version](https://justboil.me/tailwind-admin-templates/vue-dashboard/) available
|
||||
|
||||
## Table of Contents
|
||||
|
||||
* [React TypeScript version](#looking-for-react-typescript-version)
|
||||
* [Responsive layout](#responsive-layout)
|
||||
* [Mobile & tablet](#mobile--tablet)
|
||||
* [Small laptops](#small-laptops-1024px)
|
||||
* [Laptops & desktops](#laptops--desktops)
|
||||
* [Demo](#demo)
|
||||
* [Free dashboard demo](#free-dashboard-demo)
|
||||
* [Premium dashboard demo](#premium-dashboard-demo)
|
||||
* [Quick Start](#quick-start)
|
||||
* [Get code & install](#get-code--install)
|
||||
* [Vite builds](#vite-builds)
|
||||
* [Linting](#linting)
|
||||
* [Nuxt 3.x integration](#nuxt-3x-integration)
|
||||
* [Laravel 12.x integration](#laravel-12x-integration)
|
||||
* [Docs](#docs)
|
||||
* [Browser Support](#browser-support)
|
||||
* [Reporting Issues](#reporting-issues)
|
||||
* [Licensing](#licensing)
|
||||
* [Useful Links](#useful-links)
|
||||
|
||||
## Looking for React TypeScript version?
|
||||
|
||||
This is **Tailwind Vue dashboard** version
|
||||
|
||||
Looking for **Tailwind React TypeScript**? Check [Admin One - React TypeScript Tailwind dashboard](https://github.com/justboil/admin-one-react-tailwind) version
|
||||
|
||||
## Responsive layout
|
||||
|
||||
### Mobile & tablet
|
||||
|
||||
Mobile layout with hidden aside menu and collapsable cards & tables
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
### Small laptops 1024px
|
||||
|
||||
Small laptop layout with show/hide aside menu option
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
### Laptops & desktops
|
||||
|
||||
Classic layout with aside menus on the left
|
||||
|
||||
[](https://justboil.github.io/admin-one-vue-tailwind/)
|
||||
|
||||
## Demo
|
||||
|
||||
### Free Dashboard Demo
|
||||
|
||||
https://justboil.github.io/admin-one-vue-tailwind/
|
||||
|
||||
### Premium Dashboard Demo
|
||||
|
||||
https://tailwind-vue.justboil.me/
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get code & install. Then `dev` or `build` with [Vite](#vite-builds) or integrate with [Nuxt](#nuxt-3x-integration) or [Laravel](#laravel-12x-integration)
|
||||
|
||||
* [Get code & install](#get-code--install)
|
||||
* [Vite builds](#vite-builds)
|
||||
* [Linting](#linting)
|
||||
* [Nuxt 3.x integration](#nuxt-3x-integration)
|
||||
* [Laravel 12.x integration](#laravel-12x-integration)
|
||||
|
||||
### Get code & install
|
||||
|
||||
#### Get the repo
|
||||
|
||||
* [Create new repo](https://github.com/justboil/admin-one-vue-tailwind/generate) with this template
|
||||
* … or clone this repo on GitHub
|
||||
* … or [download .zip](https://github.com/justboil/admin-one-vue-tailwind/archive/master.zip) from GitHub
|
||||
|
||||
#### Install
|
||||
|
||||
`cd` to project's dir and run `npm install`
|
||||
|
||||
### Vite builds
|
||||
|
||||
[Vite](https://vitejs.dev) is next Generation Frontend Tooling featuring unbundled web-development
|
||||
|
||||
#### Hot-reloads for development
|
||||
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### Builds and minifies for production
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Serves recently built app
|
||||
|
||||
```
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
#### Lint
|
||||
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Nuxt 3.x integration
|
||||
|
||||
This dashboard can be integrated with Nuxt 3.x. [Check guide](https://github.com/justboil/admin-one-vue-tailwind/tree/master/.nuxt-guide) for more information
|
||||
|
||||
### Laravel 12.x integration
|
||||
|
||||
This dashboard can be integrated with Laravel 12.x Breeze Inertia + Vue.js stack. [Check guide](https://github.com/justboil/admin-one-vue-tailwind/tree/master/.laravel-guide) for more information
|
||||
|
||||
## Docs
|
||||
|
||||
Customization & info: https://justboil.github.io/docs/
|
||||
|
||||
## Browser Support
|
||||
|
||||
We try to make sure Dashboard works well in the latest versions of all major browsers
|
||||
|
||||
<img src="https://justboil.me/images/browsers-svg/chrome.svg" width="64" height="64" alt="Chrome"> <img src="https://justboil.me/images/browsers-svg/firefox.svg" width="64" height="64" alt="Firefox"> <img src="https://justboil.me/images/browsers-svg/edge.svg" width="64" height="64" alt="Edge"> <img src="https://justboil.me/images/browsers-svg/safari.svg" width="64" height="64" alt="Safari"> <img src="https://justboil.me/images/browsers-svg/opera.svg" width="64" height="64" alt="Opera">
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
JustBoil's free items are limited to community support on GitHub.
|
||||
|
||||
The issue list is reserved exclusively for bug reports and feature requests. That means we do not accept usage questions. If you open an issue that does not conform to the requirements, it will be closed.
|
||||
|
||||
1. Make sure that you are using the latest version of the Dashboard. Issues for outdated versions are irrelevant
|
||||
2. Provide steps to reproduce
|
||||
3. Provide an expected behavior
|
||||
4. Describe what is actually happening
|
||||
5. Platform, Browser & version as some issues may be browser specific
|
||||
|
||||
## Licensing
|
||||
|
||||
- Copyright © 2019-2025 JustBoil.me (https://justboil.me)
|
||||
- Licensed under MIT
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [JustBoil.me](https://justboil.me/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Vue.js 3](https://v3.vuejs.org/)
|
||||
- [Vite](https://vitejs.dev)
|
19
eslint.config.js
Normal file
19
eslint.config.js
Normal file
@ -0,0 +1,19 @@
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default [
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'app/files-to-ignore',
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
skipFormatting,
|
||||
]
|
62
index.html
Normal file
62
index.html
Normal file
@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="style-basic">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<title>Admin One - Vue.js 3 Tailwind dashboard template</title>
|
||||
|
||||
<meta name="description" content="Admin One - free Vue.js 3 Tailwind dashboard" />
|
||||
|
||||
<meta property="og:url" content="https://justboil.github.io/admin-one-vue-tailwind/" />
|
||||
<meta property="og:site_name" content="JustBoil.me" />
|
||||
<meta property="og:title" content="Admin One Vue.js 3 Tailwind free" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Admin One - free Vue.js 3 Tailwind dashboard with dark mode. Vite or Nuxt or Laravel"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://static.justboil.me/templates/one/repo-tailwind-vue.png"
|
||||
/>
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="960" />
|
||||
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content="Admin One Vue.js 3 Tailwind free" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Admin One - free Vue.js 3 Tailwind dashboard with dark mode. Vite or Nuxt or Laravel"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image:src"
|
||||
content="https://static.justboil.me/templates/one/repo-tailwind-vue.png"
|
||||
/>
|
||||
<meta property="twitter:image:width" content="1920" />
|
||||
<meta property="twitter:image:height" content="960" />
|
||||
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-130795909-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || []
|
||||
function gtag() {
|
||||
dataLayer.push(arguments)
|
||||
}
|
||||
gtag('js', new Date())
|
||||
gtag('config', 'UA-130795909-1')
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it
|
||||
to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
4674
package-lock.json
generated
Normal file
4674
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "admin-one-vue-tailwind",
|
||||
"version": "4.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
"axios": "^1.8.1",
|
||||
"chart.js": "^4.4.0",
|
||||
"numeral": "^2.0.6",
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@tailwindcss/forms": "^0.5.6",
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.5.1",
|
||||
"tailwindcss": "^4.0.9",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-vue-devtools": "^7.7.2"
|
||||
}
|
||||
}
|
204
public/data-sources/clients.json
Normal file
204
public/data-sources/clients.json
Normal file
@ -0,0 +1,204 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 19,
|
||||
"login": "percy64",
|
||||
"name": "Howell Hand",
|
||||
"company": "Kiehn-Green",
|
||||
"city": "Emelyside",
|
||||
"progress": 70,
|
||||
"created": "Mar 3, 2025",
|
||||
"created_mm_dd_yyyy": "03-03-2025"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"login": "dare.concepcion",
|
||||
"name": "Hope Howe",
|
||||
"company": "Nolan Inc",
|
||||
"city": "Paristown",
|
||||
"progress": 68,
|
||||
"created": "Dec 1, 2025",
|
||||
"created_mm_dd_yyyy": "12-01-2025"
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"login": "geovanni.kessler",
|
||||
"name": "Nelson Jerde",
|
||||
"company": "Nitzsche LLC",
|
||||
"city": "Jailynbury",
|
||||
"progress": 49,
|
||||
"created": "May 18, 2025",
|
||||
"created_mm_dd_yyyy": "05-18-2025"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"login": "macejkovic.dashawn",
|
||||
"name": "Kim Weimann",
|
||||
"company": "Brown-Lueilwitz",
|
||||
"city": "New Emie",
|
||||
"progress": 38,
|
||||
"created": "May 4, 2025",
|
||||
"created_mm_dd_yyyy": "05-04-2025"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"login": "hilpert.leora",
|
||||
"name": "Justice O'Reilly",
|
||||
"company": "Lakin-Muller",
|
||||
"city": "New Kacie",
|
||||
"progress": 38,
|
||||
"created": "Mar 27, 2025",
|
||||
"created_mm_dd_yyyy": "03-27-2025"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"login": "ferry.sophia",
|
||||
"name": "Adrienne Mayer III",
|
||||
"company": "Kozey, McLaughlin and Kuhn",
|
||||
"city": "Howardbury",
|
||||
"progress": 39,
|
||||
"created": "Mar 29, 2025",
|
||||
"created_mm_dd_yyyy": "03-29-2025"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"login": "gokuneva",
|
||||
"name": "Mr. Julien Ebert",
|
||||
"company": "Cormier LLC",
|
||||
"city": "South Serenaburgh",
|
||||
"progress": 29,
|
||||
"created": "Jun 25, 2025",
|
||||
"created_mm_dd_yyyy": "06-25-2025"
|
||||
},
|
||||
{
|
||||
"id": 47,
|
||||
"login": "paolo.walter",
|
||||
"name": "Lenna Smitham",
|
||||
"company": "King Inc",
|
||||
"city": "McCulloughfort",
|
||||
"progress": 59,
|
||||
"created": "Oct 8, 2025",
|
||||
"created_mm_dd_yyyy": "10-08-2025"
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"login": "lkessler",
|
||||
"name": "Travis Davis",
|
||||
"company": "Leannon and Sons",
|
||||
"city": "West Frankton",
|
||||
"progress": 52,
|
||||
"created": "Oct 20, 2025",
|
||||
"created_mm_dd_yyyy": "10-20-2025"
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"login": "shana.lang",
|
||||
"name": "Prof. Esteban Steuber",
|
||||
"company": "Langosh-Ernser",
|
||||
"city": "East Sedrick",
|
||||
"progress": 34,
|
||||
"created": "May 16, 2025",
|
||||
"created_mm_dd_yyyy": "05-16-2025"
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"login": "jewel07",
|
||||
"name": "Russell Goodwin V",
|
||||
"company": "Nolan-Stracke",
|
||||
"city": "Williamsonmouth",
|
||||
"progress": 55,
|
||||
"created": "Apr 22, 2025",
|
||||
"created_mm_dd_yyyy": "04-22-2025"
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"login": "burnice.okuneva",
|
||||
"name": "Ms. Cassidy Wiegand DVM",
|
||||
"company": "Kuhlman-Hahn",
|
||||
"city": "New Ruthiehaven",
|
||||
"progress": 76,
|
||||
"created": "Sep 16, 2025",
|
||||
"created_mm_dd_yyyy": "09-16-2025"
|
||||
},
|
||||
{
|
||||
"id": 44,
|
||||
"login": "oconnell.juanita",
|
||||
"name": "Mr. Watson Brakus PhD",
|
||||
"company": "Osinski, Bins and Kuhn",
|
||||
"city": "Lake Gloria",
|
||||
"progress": 58,
|
||||
"created": "Jun 22, 2025",
|
||||
"created_mm_dd_yyyy": "06-22-2025"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"login": "vgutmann",
|
||||
"name": "Mr. Garrison Friesen V",
|
||||
"company": "VonRueden, Rippin and Pfeffer",
|
||||
"city": "Port Cieloport",
|
||||
"progress": 39,
|
||||
"created": "Oct 19, 2025",
|
||||
"created_mm_dd_yyyy": "10-19-2025"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"login": "veum.lucio",
|
||||
"name": "Ms. Sister Morar",
|
||||
"company": "Gusikowski, Altenwerth and Abbott",
|
||||
"city": "Lake Macville",
|
||||
"progress": 34,
|
||||
"created": "Jun 11, 2025",
|
||||
"created_mm_dd_yyyy": "06-11-2025"
|
||||
},
|
||||
{
|
||||
"id": 40,
|
||||
"login": "edietrich",
|
||||
"name": "Ms. Laisha Reinger",
|
||||
"company": "Boehm PLC",
|
||||
"city": "West Alexiemouth",
|
||||
"progress": 73,
|
||||
"created": "Nov 2, 2025",
|
||||
"created_mm_dd_yyyy": "11-02-2025"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"login": "mose44",
|
||||
"name": "Cameron Lind",
|
||||
"company": "Tremblay, Padberg and Pouros",
|
||||
"city": "Naderview",
|
||||
"progress": 59,
|
||||
"created": "Sep 14, 2025",
|
||||
"created_mm_dd_yyyy": "09-14-2025"
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"login": "rau.abelardo",
|
||||
"name": "Sarai Little",
|
||||
"company": "Deckow LLC",
|
||||
"city": "Jeanieborough",
|
||||
"progress": 49,
|
||||
"created": "Jun 13, 2025",
|
||||
"created_mm_dd_yyyy": "06-13-2025"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"login": "imurazik",
|
||||
"name": "Shyann Kautzer",
|
||||
"company": "Osinski, Boehm and Kihn",
|
||||
"city": "New Alvera",
|
||||
"progress": 41,
|
||||
"created": "Feb 15, 2025",
|
||||
"created_mm_dd_yyyy": "02-15-2025"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"login": "annalise97",
|
||||
"name": "Lorna Christiansen",
|
||||
"company": "Altenwerth-Friesen",
|
||||
"city": "Port Elbertland",
|
||||
"progress": 36,
|
||||
"created": "Mar 9, 2025",
|
||||
"created_mm_dd_yyyy": "03-09-2025"
|
||||
}
|
||||
]
|
||||
}
|
36
public/data-sources/history.json
Normal file
36
public/data-sources/history.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 375.53,
|
||||
"account": "45721474",
|
||||
"name": "Home Loan Account",
|
||||
"date": "3 days ago",
|
||||
"type": "deposit",
|
||||
"business": "Turcotte"
|
||||
},
|
||||
{
|
||||
"amount": 470.26,
|
||||
"account": "94486537",
|
||||
"name": "Savings Account",
|
||||
"date": "3 days ago",
|
||||
"type": "payment",
|
||||
"business": "Murazik - Graham"
|
||||
},
|
||||
{
|
||||
"amount": 971.34,
|
||||
"account": "63189893",
|
||||
"name": "Checking Account",
|
||||
"date": "5 days ago",
|
||||
"type": "invoice",
|
||||
"business": "Fahey - Keebler"
|
||||
},
|
||||
{
|
||||
"amount": 374.63,
|
||||
"account": "74828780",
|
||||
"name": "Auto Loan Account",
|
||||
"date": "7 days ago",
|
||||
"type": "withdrawal",
|
||||
"business": "Collier - Hintz"
|
||||
}
|
||||
]
|
||||
}
|
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
7
src/App.vue
Normal file
7
src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
130
src/colors.js
Normal file
130
src/colors.js
Normal file
@ -0,0 +1,130 @@
|
||||
export const gradientBgBase = 'bg-linear-to-tr'
|
||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
|
||||
export const gradientBgDark = `${gradientBgBase} from-slate-700 via-slate-900 to-slate-800`
|
||||
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
||||
|
||||
export const colorsBgLight = {
|
||||
white: 'bg-white text-black',
|
||||
light: 'bg-white text-black dark:bg-slate-900/70 dark:text-white',
|
||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-500 border-emerald-500 text-white',
|
||||
danger: 'bg-red-500 border-red-500 text-white',
|
||||
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
||||
info: 'bg-blue-500 border-blue-500 text-white',
|
||||
}
|
||||
|
||||
export const colorsText = {
|
||||
white: 'text-black dark:text-slate-100',
|
||||
light: 'text-gray-700 dark:text-slate-400',
|
||||
contrast: 'dark:text-white',
|
||||
success: 'text-emerald-500',
|
||||
danger: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
info: 'text-blue-500',
|
||||
}
|
||||
|
||||
export const colorsOutline = {
|
||||
white: [colorsText.white, 'border-gray-100'],
|
||||
light: [colorsText.light, 'border-gray-100'],
|
||||
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'],
|
||||
success: [colorsText.success, 'border-emerald-500'],
|
||||
danger: [colorsText.danger, 'border-red-500'],
|
||||
warning: [colorsText.warning, 'border-yellow-500'],
|
||||
info: [colorsText.info, 'border-blue-500'],
|
||||
}
|
||||
|
||||
export const getButtonColor = (color, isOutlined, hasHover, isActive = false) => {
|
||||
const colors = {
|
||||
ring: {
|
||||
white: 'ring-gray-200 dark:ring-gray-500',
|
||||
whiteDark: 'ring-gray-200 dark:ring-gray-500',
|
||||
lightDark: 'ring-gray-200 dark:ring-gray-500',
|
||||
contrast: 'ring-gray-300 dark:ring-gray-400',
|
||||
success: 'ring-emerald-300 dark:ring-emerald-700',
|
||||
danger: 'ring-red-300 dark:ring-red-700',
|
||||
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
||||
info: 'ring-blue-300 dark:ring-blue-700',
|
||||
},
|
||||
active: {
|
||||
white: 'bg-gray-100',
|
||||
whiteDark: 'bg-gray-100 dark:bg-slate-800',
|
||||
lightDark: 'bg-gray-200 dark:bg-slate-700',
|
||||
contrast: 'bg-gray-700 dark:bg-slate-100',
|
||||
success: 'bg-emerald-700 dark:bg-emerald-600',
|
||||
danger: 'bg-red-700 dark:bg-red-600',
|
||||
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
||||
info: 'bg-blue-700 dark:bg-blue-600',
|
||||
},
|
||||
bg: {
|
||||
white: 'bg-white text-black',
|
||||
whiteDark: 'bg-white text-black dark:bg-slate-900 dark:text-white',
|
||||
lightDark: 'bg-gray-100 text-black dark:bg-slate-800 dark:text-white',
|
||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||
success: 'bg-emerald-600 dark:bg-emerald-500 text-white',
|
||||
danger: 'bg-red-600 dark:bg-red-500 text-white',
|
||||
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
||||
info: 'bg-blue-600 dark:bg-blue-500 text-white',
|
||||
},
|
||||
bgHover: {
|
||||
white: 'hover:bg-gray-100',
|
||||
whiteDark: 'hover:bg-gray-100 dark:hover:bg-slate-800',
|
||||
lightDark: 'hover:bg-gray-200 dark:hover:bg-slate-700',
|
||||
contrast: 'hover:bg-gray-700 dark:hover:bg-slate-100',
|
||||
success:
|
||||
'hover:bg-emerald-700 hover:border-emerald-700 dark:hover:bg-emerald-600 dark:hover:border-emerald-600',
|
||||
danger:
|
||||
'hover:bg-red-700 hover:border-red-700 dark:hover:bg-red-600 dark:hover:border-red-600',
|
||||
warning:
|
||||
'hover:bg-yellow-700 hover:border-yellow-700 dark:hover:bg-yellow-600 dark:hover:border-yellow-600',
|
||||
info: 'hover:bg-blue-700 hover:border-blue-700 dark:hover:bg-blue-600 dark:hover:border-blue-600',
|
||||
},
|
||||
borders: {
|
||||
white: 'border-white',
|
||||
whiteDark: 'border-white dark:border-slate-900',
|
||||
lightDark: 'border-gray-100 dark:border-slate-800',
|
||||
contrast: 'border-gray-800 dark:border-white',
|
||||
success: 'border-emerald-600 dark:border-emerald-500',
|
||||
danger: 'border-red-600 dark:border-red-500',
|
||||
warning: 'border-yellow-600 dark:border-yellow-500',
|
||||
info: 'border-blue-600 dark:border-blue-500',
|
||||
},
|
||||
text: {
|
||||
contrast: 'dark:text-slate-100',
|
||||
success: 'text-emerald-600 dark:text-emerald-500',
|
||||
danger: 'text-red-600 dark:text-red-500',
|
||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||
info: 'text-blue-600 dark:text-blue-500',
|
||||
},
|
||||
outlineHover: {
|
||||
contrast:
|
||||
'hover:bg-gray-800 hover:text-gray-100 dark:hover:bg-slate-100 dark:hover:text-black',
|
||||
success:
|
||||
'hover:bg-emerald-600 hover:text-white hover:text-white dark:hover:text-white dark:hover:border-emerald-600',
|
||||
danger:
|
||||
'hover:bg-red-600 hover:text-white hover:text-white dark:hover:text-white dark:hover:border-red-600',
|
||||
warning:
|
||||
'hover:bg-yellow-600 hover:text-white hover:text-white dark:hover:text-white dark:hover:border-yellow-600',
|
||||
info: 'hover:bg-blue-600 hover:text-white dark:hover:text-white dark:hover:border-blue-600',
|
||||
},
|
||||
}
|
||||
|
||||
if (!colors.bg[color]) {
|
||||
return color
|
||||
}
|
||||
|
||||
const isOutlinedProcessed = isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0
|
||||
|
||||
const base = [colors.borders[color], colors.ring[color]]
|
||||
|
||||
if (isActive) {
|
||||
base.push(colors.active[color])
|
||||
} else {
|
||||
base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color])
|
||||
}
|
||||
|
||||
if (hasHover) {
|
||||
base.push(isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color])
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
36
src/components/AsideMenu.vue
Normal file
36
src/components/AsideMenu.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import AsideMenuLayer from '@/components/AsideMenuLayer.vue'
|
||||
import OverlayLayer from '@/components/OverlayLayer.vue'
|
||||
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isAsideMobileExpanded: Boolean,
|
||||
isAsideLgActive: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click', 'aside-lg-close-click'])
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
|
||||
const asideLgCloseClick = (event) => {
|
||||
emit('aside-lg-close-click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AsideMenuLayer
|
||||
:menu="menu"
|
||||
:class="[
|
||||
isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0',
|
||||
{ 'lg:hidden xl:flex': !isAsideLgActive },
|
||||
]"
|
||||
@menu-click="menuClick"
|
||||
@aside-lg-close-click="asideLgCloseClick"
|
||||
/>
|
||||
<OverlayLayer v-show="isAsideLgActive" z-index="z-30" @overlay-click="asideLgCloseClick" />
|
||||
</template>
|
88
src/components/AsideMenuItem.vue
Normal file
88
src/components/AsideMenuItem.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { mdiMinus, mdiPlus } from '@mdi/js'
|
||||
import { getButtonColor } from '@/colors.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import AsideMenuList from '@/components/AsideMenuList.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isDropdownList: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
const hasColor = computed(() => props.item && props.item.color)
|
||||
|
||||
const asideMenuItemActiveStyle = computed(() =>
|
||||
hasColor.value ? '' : 'aside-menu-item-active font-bold',
|
||||
)
|
||||
|
||||
const isDropdownActive = ref(false)
|
||||
|
||||
const componentClass = computed(() => [
|
||||
props.isDropdownList ? 'py-3 px-6 text-sm' : 'py-3',
|
||||
hasColor.value
|
||||
? getButtonColor(props.item.color, false, true)
|
||||
: `aside-menu-item dark:text-slate-300 dark:hover:text-white`,
|
||||
])
|
||||
|
||||
const hasDropdown = computed(() => !!props.item.menu)
|
||||
|
||||
const menuClick = (event) => {
|
||||
emit('menu-click', event, props.item)
|
||||
|
||||
if (hasDropdown.value) {
|
||||
isDropdownActive.value = !isDropdownActive.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<component
|
||||
:is="item.to ? RouterLink : 'a'"
|
||||
v-slot="vSlot"
|
||||
:to="item.to ?? null"
|
||||
:href="item.href ?? null"
|
||||
:target="item.target ?? null"
|
||||
class="flex cursor-pointer"
|
||||
:class="componentClass"
|
||||
@click="menuClick"
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="item.icon"
|
||||
:path="item.icon"
|
||||
class="flex-none"
|
||||
:class="[vSlot && vSlot.isExactActive ? asideMenuItemActiveStyle : '']"
|
||||
w="w-16"
|
||||
:size="18"
|
||||
/>
|
||||
<span
|
||||
class="grow text-ellipsis line-clamp-1"
|
||||
:class="[
|
||||
{ 'pr-12': !hasDropdown },
|
||||
vSlot && vSlot.isExactActive ? asideMenuItemActiveStyle : '',
|
||||
]"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="hasDropdown"
|
||||
:path="isDropdownActive ? mdiMinus : mdiPlus"
|
||||
class="flex-none"
|
||||
:class="[vSlot && vSlot.isExactActive ? asideMenuItemActiveStyle : '']"
|
||||
w="w-12"
|
||||
/>
|
||||
</component>
|
||||
<AsideMenuList
|
||||
v-if="hasDropdown"
|
||||
:menu="item.menu"
|
||||
:class="['aside-menu-dropdown', isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden']"
|
||||
is-dropdown-list
|
||||
/>
|
||||
</li>
|
||||
</template>
|
58
src/components/AsideMenuLayer.vue
Normal file
58
src/components/AsideMenuLayer.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
||||
import { computed } from 'vue'
|
||||
import AsideMenuList from '@/components/AsideMenuList.vue'
|
||||
import AsideMenuItem from '@/components/AsideMenuItem.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click', 'aside-lg-close-click'])
|
||||
|
||||
const logoutItem = computed(() => ({
|
||||
label: 'Logout',
|
||||
icon: mdiLogout,
|
||||
color: 'info',
|
||||
isLogout: true,
|
||||
}))
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
|
||||
const asideLgCloseClick = (event) => {
|
||||
emit('aside-lg-close-click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
id="aside"
|
||||
class="lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden"
|
||||
>
|
||||
<div class="aside lg:rounded-2xl flex-1 flex flex-col overflow-hidden dark:bg-slate-900">
|
||||
<div class="aside-brand flex flex-row h-14 items-center justify-between dark:bg-slate-900">
|
||||
<div class="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
<b class="font-black">One</b>
|
||||
</div>
|
||||
<button class="hidden lg:inline-block xl:hidden p-3" @click.prevent="asideLgCloseClick">
|
||||
<BaseIcon :path="mdiClose" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden aside-scrollbars dark:aside-scrollbars-[slate]"
|
||||
>
|
||||
<AsideMenuList :menu="menu" @menu-click="menuClick" />
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<AsideMenuItem :item="logoutItem" @menu-click="menuClick" />
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
29
src/components/AsideMenuList.vue
Normal file
29
src/components/AsideMenuList.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import AsideMenuItem from '@/components/AsideMenuItem.vue'
|
||||
|
||||
defineProps({
|
||||
isDropdownList: Boolean,
|
||||
menu: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
<AsideMenuItem
|
||||
v-for="(item, index) in menu"
|
||||
:key="index"
|
||||
:item="item"
|
||||
:is-dropdown-list="isDropdownList"
|
||||
@menu-click="menuClick"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
122
src/components/BaseButton.vue
Normal file
122
src/components/BaseButton.vue
Normal file
@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { getButtonColor } from '@/colors.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconSize: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
to: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'white',
|
||||
},
|
||||
as: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
small: Boolean,
|
||||
outline: Boolean,
|
||||
active: Boolean,
|
||||
disabled: Boolean,
|
||||
roundedFull: Boolean,
|
||||
})
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.as) {
|
||||
return props.as
|
||||
}
|
||||
|
||||
if (props.to) {
|
||||
return RouterLink
|
||||
}
|
||||
|
||||
if (props.href) {
|
||||
return 'a'
|
||||
}
|
||||
|
||||
return 'button'
|
||||
})
|
||||
|
||||
const computedType = computed(() => {
|
||||
if (is.value === 'button') {
|
||||
return props.type ?? 'button'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const labelClass = computed(() => (props.small && props.icon ? 'px-1' : 'px-2'))
|
||||
|
||||
const componentClass = computed(() => {
|
||||
const base = [
|
||||
'inline-flex',
|
||||
'justify-center',
|
||||
'items-center',
|
||||
'whitespace-nowrap',
|
||||
'focus:outline-hidden',
|
||||
'transition-colors',
|
||||
'focus:ring-3',
|
||||
'duration-150',
|
||||
'border',
|
||||
props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
|
||||
props.roundedFull ? 'rounded-full' : 'rounded-sm',
|
||||
getButtonColor(props.color, props.outline, !props.disabled, props.active),
|
||||
]
|
||||
|
||||
if (!props.label && props.icon) {
|
||||
base.push('p-1')
|
||||
} else if (props.small) {
|
||||
base.push('text-sm', props.roundedFull ? 'px-3 py-1' : 'p-1')
|
||||
} else {
|
||||
base.push('py-2', props.roundedFull ? 'px-6' : 'px-3')
|
||||
}
|
||||
|
||||
if (props.disabled) {
|
||||
base.push(props.outline ? 'opacity-50' : 'opacity-70')
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="is"
|
||||
:class="componentClass"
|
||||
:href="href"
|
||||
:type="computedType"
|
||||
:to="to"
|
||||
:target="target"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<BaseIcon v-if="icon" :path="icon" :size="iconSize" />
|
||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||
</component>
|
||||
</template>
|
56
src/components/BaseButtons.vue
Normal file
56
src/components/BaseButtons.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script>
|
||||
import { h, defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseButtons',
|
||||
props: {
|
||||
noWrap: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'justify-start',
|
||||
},
|
||||
classAddon: {
|
||||
type: String,
|
||||
default: 'mr-3 last:mr-0 mb-3',
|
||||
},
|
||||
mb: {
|
||||
type: String,
|
||||
default: '-mb-3',
|
||||
},
|
||||
},
|
||||
render() {
|
||||
const hasSlot = this.$slots && this.$slots.default
|
||||
|
||||
const parentClass = [
|
||||
'flex',
|
||||
'items-center',
|
||||
this.type,
|
||||
this.noWrap ? 'flex-nowrap' : 'flex-wrap',
|
||||
]
|
||||
|
||||
if (this.mb) {
|
||||
parentClass.push(this.mb)
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ class: parentClass },
|
||||
hasSlot
|
||||
? this.$slots.default().map((element) => {
|
||||
if (element && element.children && typeof element.children === 'object') {
|
||||
return h(
|
||||
element,
|
||||
{},
|
||||
element.children.map((child) => {
|
||||
return h(child, { class: [this.classAddon] })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return h(element, { class: [this.classAddon] })
|
||||
})
|
||||
: null,
|
||||
)
|
||||
},
|
||||
})
|
||||
</script>
|
16
src/components/BaseDivider.vue
Normal file
16
src/components/BaseDivider.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
navBar: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<hr
|
||||
:class="
|
||||
props.navBar
|
||||
? 'hidden lg:block lg:my-0.5 dark:border-slate-700'
|
||||
: 'my-6 -mx-6 dark:border-slate-800'
|
||||
"
|
||||
class="border-t border-gray-100"
|
||||
/>
|
||||
</template>
|
35
src/components/BaseIcon.vue
Normal file
35
src/components/BaseIcon.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
w: {
|
||||
type: String,
|
||||
default: 'w-6',
|
||||
},
|
||||
h: {
|
||||
type: String,
|
||||
default: 'h-6',
|
||||
},
|
||||
size: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const spanClass = computed(() => `inline-flex justify-center items-center ${props.w} ${props.h}`)
|
||||
|
||||
const iconSize = computed(() => props.size ?? 16)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="spanClass">
|
||||
<svg viewBox="0 0 24 24" :width="iconSize" :height="iconSize" class="inline-block">
|
||||
<path fill="currentColor" :d="path" />
|
||||
</svg>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
38
src/components/BaseLevel.vue
Normal file
38
src/components/BaseLevel.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { h, defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseLevel',
|
||||
props: {
|
||||
mobile: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'justify-between',
|
||||
},
|
||||
},
|
||||
render() {
|
||||
const parentClass = [this.type, 'items-center']
|
||||
|
||||
const parentMobileClass = ['flex']
|
||||
|
||||
const parentBaseClass = ['block', 'md:flex']
|
||||
|
||||
const childBaseClass = ['flex', 'items-center', 'justify-center']
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: parentClass.concat(this.mobile ? parentMobileClass : parentBaseClass),
|
||||
},
|
||||
this.$slots.default().map((element, index) => {
|
||||
const childClass =
|
||||
!this.mobile && this.$slots.default().length > index + 1
|
||||
? childBaseClass.concat(['mb-6', 'md:mb-0'])
|
||||
: childBaseClass
|
||||
|
||||
return h('div', { class: childClass }, [element])
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
</script>
|
64
src/components/CardBox.vue
Normal file
64
src/components/CardBox.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
import CardBoxComponentBody from '@/components/CardBoxComponentBody.vue'
|
||||
import CardBoxComponentFooter from '@/components/CardBoxComponentFooter.vue'
|
||||
|
||||
const props = defineProps({
|
||||
rounded: {
|
||||
type: String,
|
||||
default: 'rounded-2xl',
|
||||
},
|
||||
flex: {
|
||||
type: String,
|
||||
default: 'flex-col',
|
||||
},
|
||||
hasComponentLayout: Boolean,
|
||||
hasTable: Boolean,
|
||||
isForm: Boolean,
|
||||
isHoverable: Boolean,
|
||||
isModal: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasFooterSlot = computed(() => slots.footer && !!slots.footer())
|
||||
|
||||
const componentClass = computed(() => {
|
||||
const base = [
|
||||
props.rounded,
|
||||
props.flex,
|
||||
props.isModal ? 'dark:bg-slate-900' : 'dark:bg-slate-900/70',
|
||||
]
|
||||
|
||||
if (props.isHoverable) {
|
||||
base.push('hover:shadow-lg transition-shadow duration-500')
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const submit = (event) => {
|
||||
emit('submit', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="isForm ? 'form' : 'div'"
|
||||
:class="componentClass"
|
||||
class="bg-white flex"
|
||||
@submit="submit"
|
||||
>
|
||||
<slot v-if="hasComponentLayout" />
|
||||
<template v-else>
|
||||
<CardBoxComponentBody :no-padding="hasTable">
|
||||
<slot />
|
||||
</CardBoxComponentBody>
|
||||
<CardBoxComponentFooter v-if="hasFooterSlot">
|
||||
<slot name="footer" />
|
||||
</CardBoxComponentFooter>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
82
src/components/CardBoxClient.vue
Normal file
82
src/components/CardBoxClient.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { mdiTrendingDown, mdiTrendingUp, mdiTrendingNeutral } from '@mdi/js'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import PillTag from '@/components/PillTag.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
login: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const pillType = computed(() => {
|
||||
if (props.type) {
|
||||
return props.type
|
||||
}
|
||||
|
||||
if (props.progress) {
|
||||
if (props.progress >= 60) {
|
||||
return 'success'
|
||||
}
|
||||
if (props.progress >= 40) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const pillIcon = computed(() => {
|
||||
return {
|
||||
success: mdiTrendingUp,
|
||||
warning: mdiTrendingNeutral,
|
||||
danger: mdiTrendingDown,
|
||||
info: null,
|
||||
}[pillType.value]
|
||||
})
|
||||
|
||||
const pillText = computed(() => props.text ?? `${props.progress}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardBox class="mb-6 last:mb-0">
|
||||
<BaseLevel>
|
||||
<BaseLevel type="justify-start">
|
||||
<UserAvatar class="w-12 h-12 mr-6" :username="name" />
|
||||
<div class="text-center md:text-left overflow-hidden">
|
||||
<h4 class="text-xl text-ellipsis">
|
||||
{{ name }}
|
||||
</h4>
|
||||
<p class="text-gray-500 dark:text-slate-400">{{ date }} @ {{ login }}</p>
|
||||
</div>
|
||||
</BaseLevel>
|
||||
<PillTag :color="pillType" :label="pillText" :icon="pillIcon" />
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</template>
|
11
src/components/CardBoxComponentBody.vue
Normal file
11
src/components/CardBoxComponentBody.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
noPadding: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1" :class="{ 'p-6': !noPadding }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
5
src/components/CardBoxComponentEmpty.vue
Normal file
5
src/components/CardBoxComponentEmpty.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="text-center py-24 text-gray-500 dark:text-slate-400">
|
||||
<p>Nothing's here…</p>
|
||||
</div>
|
||||
</template>
|
5
src/components/CardBoxComponentFooter.vue
Normal file
5
src/components/CardBoxComponentFooter.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<footer class="p-6">
|
||||
<slot />
|
||||
</footer>
|
||||
</template>
|
40
src/components/CardBoxComponentHeader.vue
Normal file
40
src/components/CardBoxComponentHeader.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
buttonIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['button-click'])
|
||||
|
||||
const buttonClick = (event) => {
|
||||
emit('button-click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-stretch border-b border-gray-100 dark:border-slate-800">
|
||||
<div class="flex items-center py-3 grow font-bold" :class="[icon ? 'px-4' : 'px-6']">
|
||||
<BaseIcon v-if="icon" :path="icon" class="mr-3" />
|
||||
{{ title }}
|
||||
</div>
|
||||
<button
|
||||
v-if="buttonIcon"
|
||||
class="flex items-center p-2 justify-center ring-blue-700 focus:ring-3"
|
||||
@click="buttonClick"
|
||||
>
|
||||
<BaseIcon :path="buttonIcon" />
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
17
src/components/CardBoxComponentTitle.vue
Normal file
17
src/components/CardBoxComponentTitle.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h1 class="text-2xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
83
src/components/CardBoxModal.vue
Normal file
83
src/components/CardBoxModal.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { mdiClose } from '@mdi/js'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import BaseButtons from '@/components/BaseButtons.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import OverlayLayer from '@/components/OverlayLayer.vue'
|
||||
import CardBoxComponentTitle from '@/components/CardBoxComponentTitle.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: 'Done',
|
||||
},
|
||||
hasCancel: Boolean,
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean],
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'cancel', 'confirm'])
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const confirmCancel = (mode) => {
|
||||
value.value = false
|
||||
emit(mode)
|
||||
}
|
||||
|
||||
const confirm = () => confirmCancel('confirm')
|
||||
|
||||
const cancel = () => confirmCancel('cancel')
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && value.value) {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverlayLayer v-show="value" @overlay-click="cancel">
|
||||
<CardBox
|
||||
v-show="value"
|
||||
class="shadow-lg max-h-modal w-11/12 md:w-3/5 lg:w-2/5 xl:w-4/12 z-50"
|
||||
is-modal
|
||||
>
|
||||
<CardBoxComponentTitle :title="title">
|
||||
<BaseButton
|
||||
v-if="hasCancel"
|
||||
:icon="mdiClose"
|
||||
color="whiteDark"
|
||||
small
|
||||
rounded-full
|
||||
@click.prevent="cancel"
|
||||
/>
|
||||
</CardBoxComponentTitle>
|
||||
|
||||
<div class="space-y-3">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButtons>
|
||||
<BaseButton :label="buttonLabel" :color="button" @click="confirm" />
|
||||
<BaseButton v-if="hasCancel" label="Cancel" :color="button" outline @click="cancel" />
|
||||
</BaseButtons>
|
||||
</template>
|
||||
</CardBox>
|
||||
</OverlayLayer>
|
||||
</template>
|
83
src/components/CardBoxTransaction.vue
Normal file
83
src/components/CardBoxTransaction.vue
Normal file
@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { mdiCashMinus, mdiCashPlus, mdiReceipt, mdiCreditCardOutline } from '@mdi/js'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import PillTag from '@/components/PillTag.vue'
|
||||
import IconRounded from '@/components/IconRounded.vue'
|
||||
|
||||
const props = defineProps({
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
business: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
account: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.type === 'withdrawal') {
|
||||
return {
|
||||
icon: mdiCashMinus,
|
||||
type: 'danger',
|
||||
}
|
||||
} else if (props.type === 'deposit') {
|
||||
return {
|
||||
icon: mdiCashPlus,
|
||||
type: 'success',
|
||||
}
|
||||
} else if (props.type === 'invoice') {
|
||||
return {
|
||||
icon: mdiReceipt,
|
||||
type: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: mdiCreditCardOutline,
|
||||
type: 'info',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardBox class="mb-6 last:mb-0">
|
||||
<BaseLevel>
|
||||
<BaseLevel type="justify-start">
|
||||
<IconRounded :icon="icon.icon" :color="icon.type" class="md:mr-6" />
|
||||
<div class="text-center space-y-1 md:text-left md:mr-6">
|
||||
<h4 class="text-xl">${{ amount }}</h4>
|
||||
<p class="text-gray-500 dark:text-slate-400">
|
||||
<b>{{ date }}</b> via {{ business }}
|
||||
</p>
|
||||
</div>
|
||||
</BaseLevel>
|
||||
<div class="text-center md:text-right space-y-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ name }}
|
||||
</p>
|
||||
<div>
|
||||
<PillTag :color="icon.type" :label="type" small />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</template>
|
64
src/components/CardBoxWidget.vue
Normal file
64
src/components/CardBoxWidget.vue
Normal file
@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { mdiCog } from '@mdi/js'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import NumberDynamic from '@/components/NumberDynamic.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import PillTagTrend from '@/components/PillTagTrend.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
defineProps({
|
||||
number: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
trend: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
trendType: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardBox>
|
||||
<BaseLevel v-if="trend" class="mb-3" mobile>
|
||||
<PillTagTrend :trend="trend" :trend-type="trendType" small />
|
||||
<BaseButton :icon="mdiCog" icon-w="w-4" icon-h="h-4" color="lightDark" small />
|
||||
</BaseLevel>
|
||||
<BaseLevel mobile>
|
||||
<div>
|
||||
<h3 class="text-lg leading-tight text-gray-500 dark:text-slate-400">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<h1 class="text-3xl leading-tight font-semibold">
|
||||
<NumberDynamic :value="number" :prefix="prefix" :suffix="suffix" />
|
||||
</h1>
|
||||
</div>
|
||||
<BaseIcon v-if="icon" :path="icon" size="48" w="" h="h-16" :class="color" />
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</template>
|
62
src/components/Charts/LineChart.vue
Normal file
62
src/components/Charts/LineChart.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LineController,
|
||||
LinearScale,
|
||||
CategoryScale,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const root = ref(null)
|
||||
|
||||
let chart
|
||||
|
||||
Chart.register(LineElement, PointElement, LineController, LinearScale, CategoryScale, Tooltip)
|
||||
|
||||
onMounted(() => {
|
||||
chart = new Chart(root.value, {
|
||||
type: 'line',
|
||||
data: props.data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
display: false,
|
||||
},
|
||||
x: {
|
||||
display: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const chartData = computed(() => props.data)
|
||||
|
||||
watch(chartData, (data) => {
|
||||
if (chart) {
|
||||
chart.data = data
|
||||
chart.update()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="root" />
|
||||
</template>
|
54
src/components/Charts/chart.config.js
Normal file
54
src/components/Charts/chart.config.js
Normal file
@ -0,0 +1,54 @@
|
||||
export const chartColors = {
|
||||
default: {
|
||||
primary: '#00D1B2',
|
||||
info: '#209CEE',
|
||||
danger: '#FF3860',
|
||||
},
|
||||
}
|
||||
|
||||
const randomChartData = (n) => {
|
||||
const data = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
data.push(Math.round(Math.random() * 200))
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
const datasetObject = (color, points) => {
|
||||
return {
|
||||
fill: false,
|
||||
borderColor: chartColors.default[color],
|
||||
borderWidth: 2,
|
||||
borderDash: [],
|
||||
borderDashOffset: 0.0,
|
||||
pointBackgroundColor: chartColors.default[color],
|
||||
pointBorderColor: 'rgba(255,255,255,0)',
|
||||
pointHoverBackgroundColor: chartColors.default[color],
|
||||
pointBorderWidth: 20,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBorderWidth: 15,
|
||||
pointRadius: 4,
|
||||
data: randomChartData(points),
|
||||
tension: 0.5,
|
||||
cubicInterpolationMode: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
export const sampleChartData = (points = 9) => {
|
||||
const labels = []
|
||||
|
||||
for (let i = 1; i <= points; i++) {
|
||||
labels.push(`0${i}`)
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
datasetObject('primary', points),
|
||||
datasetObject('info', points),
|
||||
datasetObject('danger', points),
|
||||
],
|
||||
}
|
||||
}
|
23
src/components/FooterBar.vue
Normal file
23
src/components/FooterBar.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { containerMaxW } from '@/config.js'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import JustboilLogo from '@/components/JustboilLogo.vue'
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="py-2 px-6" :class="containerMaxW">
|
||||
<BaseLevel>
|
||||
<div class="text-center md:text-left">
|
||||
<b>©{{ year }}, <a href="https://justboil.me/" target="_blank">JustBoil.me</a>.</b>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="md:py-2">
|
||||
<a href="https://justboil.me">
|
||||
<JustboilLogo class="w-auto h-8 md:h-6" />
|
||||
</a>
|
||||
</div>
|
||||
</BaseLevel>
|
||||
</footer>
|
||||
</template>
|
46
src/components/FormCheckRadio.vue
Normal file
46
src/components/FormCheckRadio.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'checkbox',
|
||||
validator: (value) => ['checkbox', 'radio', 'switch'].includes(value),
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [Array, String, Number, Boolean],
|
||||
default: null,
|
||||
},
|
||||
inputValue: {
|
||||
type: [String, Number, Boolean],
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const computedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const inputType = computed(() => (props.type === 'radio' ? 'radio' : 'checkbox'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :class="type">
|
||||
<input v-model="computedValue" :type="inputType" :name="name" :value="inputValue" />
|
||||
<span class="check" />
|
||||
<span class="pl-2">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
54
src/components/FormCheckRadioGroup.vue
Normal file
54
src/components/FormCheckRadioGroup.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import FormCheckRadio from '@/components/FormCheckRadio.vue'
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'checkbox',
|
||||
validator: (value) => ['checkbox', 'radio', 'switch'].includes(value),
|
||||
},
|
||||
componentClass: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isColumn: Boolean,
|
||||
modelValue: {
|
||||
type: [Array, String, Number, Boolean],
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const computedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-start flex-wrap -mb-3" :class="{ 'flex-col': isColumn }">
|
||||
<FormCheckRadio
|
||||
v-for="(value, key) in options"
|
||||
:key="key"
|
||||
v-model="computedValue"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:input-value="key"
|
||||
:label="value"
|
||||
:class="componentClass"
|
||||
class="mr-6 mb-3 last:mr-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
165
src/components/FormControl.vue
Normal file
165
src/components/FormControl.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import FormControlIcon from '@/components/FormControlIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
maxlength: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
inputmode: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array, Object],
|
||||
default: '',
|
||||
},
|
||||
required: Boolean,
|
||||
borderless: Boolean,
|
||||
transparent: Boolean,
|
||||
ctrlKFocus: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'setRef'])
|
||||
|
||||
const computedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const inputElClass = computed(() => {
|
||||
const base = [
|
||||
'px-3 py-2 max-w-full focus:ring-3 focus:outline-hidden border-gray-700 rounded-sm w-full',
|
||||
'dark:placeholder-gray-400',
|
||||
computedType.value === 'textarea' ? 'h-24' : 'h-12',
|
||||
props.borderless ? 'border-0' : 'border',
|
||||
props.transparent ? 'bg-transparent' : 'bg-white dark:bg-slate-800',
|
||||
]
|
||||
|
||||
if (props.icon) {
|
||||
base.push('pl-10')
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const computedType = computed(() => (props.options ? 'select' : props.type))
|
||||
|
||||
const controlIconH = computed(() => (props.type === 'textarea' ? 'h-full' : 'h-12'))
|
||||
|
||||
const mainStore = useMainStore()
|
||||
|
||||
const selectEl = ref(null)
|
||||
|
||||
const textareaEl = ref(null)
|
||||
|
||||
const inputEl = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (selectEl.value) {
|
||||
emit('setRef', selectEl.value)
|
||||
} else if (textareaEl.value) {
|
||||
emit('setRef', textareaEl.value)
|
||||
} else {
|
||||
emit('setRef', inputEl.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (props.ctrlKFocus) {
|
||||
const fieldFocusHook = (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
inputEl.value.focus()
|
||||
} else if (e.key === 'Escape') {
|
||||
inputEl.value.blur()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!mainStore.isFieldFocusRegistered) {
|
||||
window.addEventListener('keydown', fieldFocusHook)
|
||||
mainStore.isFieldFocusRegistered = true
|
||||
} else {
|
||||
// console.error('Duplicate field focus event')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', fieldFocusHook)
|
||||
mainStore.isFieldFocusRegistered = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
v-if="computedType === 'select'"
|
||||
:id="id"
|
||||
v-model="computedValue"
|
||||
:name="name"
|
||||
:class="inputElClass"
|
||||
>
|
||||
<option v-for="option in options" :key="option.id ?? option" :value="option">
|
||||
{{ option.label ?? option }}
|
||||
</option>
|
||||
</select>
|
||||
<textarea
|
||||
v-else-if="computedType === 'textarea'"
|
||||
:id="id"
|
||||
v-model="computedValue"
|
||||
:class="inputElClass"
|
||||
:name="name"
|
||||
:maxlength="maxlength"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="id"
|
||||
ref="inputEl"
|
||||
v-model="computedValue"
|
||||
:name="name"
|
||||
:maxlength="maxlength"
|
||||
:inputmode="inputmode"
|
||||
:autocomplete="autocomplete"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:type="computedType"
|
||||
:class="inputElClass"
|
||||
/>
|
||||
<FormControlIcon v-if="icon" :icon="icon" :h="controlIconH" />
|
||||
</div>
|
||||
</template>
|
23
src/components/FormControlIcon.vue
Normal file
23
src/components/FormControlIcon.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
h: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseIcon
|
||||
:path="icon"
|
||||
w="w-10"
|
||||
:h="h"
|
||||
class="absolute top-0 left-0 z-10 pointer-events-none text-gray-500 dark:text-slate-400"
|
||||
/>
|
||||
</template>
|
47
src/components/FormField.vue
Normal file
47
src/components/FormField.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
labelFor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const wrapperClass = computed(() => {
|
||||
const base = []
|
||||
const slotsLength = slots.default().length
|
||||
|
||||
if (slotsLength > 1) {
|
||||
base.push('grid grid-cols-1 gap-3')
|
||||
}
|
||||
|
||||
if (slotsLength === 2) {
|
||||
base.push('md:grid-cols-2')
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6 last:mb-0">
|
||||
<label v-if="label" :for="labelFor" class="block font-bold mb-2">{{ label }}</label>
|
||||
<div :class="wrapperClass">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="help" class="text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
{{ help }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
114
src/components/FormFilePicker.vue
Normal file
114
src/components/FormFilePicker.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { mdiUpload } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, File, Array],
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: mdiUpload,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
isRoundIcon: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const root = ref(null)
|
||||
|
||||
const file = ref(props.modelValue)
|
||||
|
||||
const showFilename = computed(() => !props.isRoundIcon && file.value)
|
||||
|
||||
const modelValueProp = computed(() => props.modelValue)
|
||||
|
||||
watch(modelValueProp, (value) => {
|
||||
file.value = value
|
||||
|
||||
if (!value) {
|
||||
root.value.input.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const upload = (event) => {
|
||||
const value = event.target.files || event.dataTransfer.files
|
||||
|
||||
file.value = value[0]
|
||||
|
||||
emit('update:modelValue', file.value)
|
||||
|
||||
// Use this as an example for handling file uploads
|
||||
// let formData = new FormData()
|
||||
// formData.append('file', file.value)
|
||||
|
||||
// const mediaStoreRoute = `/your-route/`
|
||||
|
||||
// axios
|
||||
// .post(mediaStoreRoute, formData, {
|
||||
// headers: {
|
||||
// 'Content-Type': 'multipart/form-data'
|
||||
// },
|
||||
// onUploadProgress: progressEvent
|
||||
// })
|
||||
// .then(r => {
|
||||
//
|
||||
// })
|
||||
// .catch(err => {
|
||||
//
|
||||
// })
|
||||
}
|
||||
|
||||
// const uploadPercent = ref(0)
|
||||
//
|
||||
// const progressEvent = progressEvent => {
|
||||
// uploadPercent.value = Math.round(
|
||||
// (progressEvent.loaded * 100) / progressEvent.total
|
||||
// )
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-stretch justify-start relative">
|
||||
<label class="inline-flex">
|
||||
<BaseButton
|
||||
as="a"
|
||||
:class="{ 'w-12 h-12': isRoundIcon, 'rounded-r-none': showFilename }"
|
||||
:icon-size="isRoundIcon ? 24 : undefined"
|
||||
:label="isRoundIcon ? null : label"
|
||||
:icon="icon"
|
||||
:color="color"
|
||||
:rounded-full="isRoundIcon"
|
||||
/>
|
||||
<input
|
||||
ref="root"
|
||||
type="file"
|
||||
class="absolute top-0 left-0 w-full h-full opacity-0 outline-hidden cursor-pointer -z-1"
|
||||
:accept="accept"
|
||||
@input="upload"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
v-if="showFilename"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-slate-800 border-gray-200 dark:border-slate-700 border rounded-r"
|
||||
>
|
||||
<span class="text-ellipsis line-clamp-1">
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
35
src/components/IconRounded.vue
Normal file
35
src/components/IconRounded.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { colorsText, colorsBgLight } from '@/colors.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
w: {
|
||||
type: String,
|
||||
default: 'w-12',
|
||||
},
|
||||
h: {
|
||||
type: String,
|
||||
default: 'h-12',
|
||||
},
|
||||
bg: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseIcon
|
||||
:path="icon"
|
||||
:w="w"
|
||||
:h="h"
|
||||
size="24"
|
||||
class="rounded-full"
|
||||
:class="bg ? colorsBgLight[color] : [colorsText[color], 'bg-gray-50 dark:bg-slate-800']"
|
||||
/>
|
||||
</template>
|
8
src/components/JustboilLogo.vue
Normal file
8
src/components/JustboilLogo.vue
Normal file
File diff suppressed because one or more lines are too long
46
src/components/NavBar.vue
Normal file
46
src/components/NavBar.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { mdiClose, mdiDotsVertical } from '@mdi/js'
|
||||
import { containerMaxW } from '@/config.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import NavBarMenuList from '@/components/NavBarMenuList.vue'
|
||||
import NavBarItemPlain from '@/components/NavBarItemPlain.vue'
|
||||
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
|
||||
const isMenuNavBarActive = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="top-0 inset-x-0 fixed bg-gray-50 h-14 z-30 transition-position w-screen lg:w-auto dark:bg-slate-800"
|
||||
>
|
||||
<div class="flex lg:items-stretch" :class="containerMaxW">
|
||||
<div class="flex flex-1 items-stretch h-14">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="flex-none items-stretch flex h-14 lg:hidden">
|
||||
<NavBarItemPlain @click.prevent="isMenuNavBarActive = !isMenuNavBarActive">
|
||||
<BaseIcon :path="isMenuNavBarActive ? mdiClose : mdiDotsVertical" size="24" />
|
||||
</NavBarItemPlain>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 bg-gray-50 shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-slate-800"
|
||||
:class="[isMenuNavBarActive ? 'block' : 'hidden']"
|
||||
>
|
||||
<NavBarMenuList :menu="menu" @menu-click="menuClick" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
127
src/components/NavBarItem.vue
Normal file
127
src/components/NavBarItem.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useMainStore } from '@/stores/main.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import UserAvatarCurrentUser from '@/components/UserAvatarCurrentUser.vue'
|
||||
import NavBarMenuList from '@/components/NavBarMenuList.vue'
|
||||
import BaseDivider from '@/components/BaseDivider.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.item.href) {
|
||||
return 'a'
|
||||
}
|
||||
|
||||
if (props.item.to) {
|
||||
return RouterLink
|
||||
}
|
||||
|
||||
return 'div'
|
||||
})
|
||||
|
||||
const componentClass = computed(() => {
|
||||
const base = [
|
||||
isDropdownActive.value
|
||||
? `navbar-item-label-active dark:text-slate-400`
|
||||
: `navbar-item-label dark:text-white dark:hover:text-slate-400`,
|
||||
props.item.menu ? 'lg:py-2 lg:px-3' : 'py-2 px-3',
|
||||
]
|
||||
|
||||
if (props.item.isDesktopNoLabel) {
|
||||
base.push('lg:w-16', 'lg:justify-center')
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const itemLabel = computed(() =>
|
||||
props.item.isCurrentUser ? useMainStore().userName : props.item.label,
|
||||
)
|
||||
|
||||
const isDropdownActive = ref(false)
|
||||
|
||||
const menuClick = (event) => {
|
||||
emit('menu-click', event, props.item)
|
||||
|
||||
if (props.item.menu) {
|
||||
isDropdownActive.value = !isDropdownActive.value
|
||||
}
|
||||
}
|
||||
|
||||
const menuClickDropdown = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
|
||||
const root = ref(null)
|
||||
|
||||
const forceClose = (event) => {
|
||||
if (root.value && !root.value.contains(event.target)) {
|
||||
isDropdownActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.item.menu) {
|
||||
window.addEventListener('click', forceClose)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.item.menu) {
|
||||
window.removeEventListener('click', forceClose)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseDivider v-if="item.isDivider" nav-bar />
|
||||
<component
|
||||
:is="is"
|
||||
v-else
|
||||
ref="root"
|
||||
class="block lg:flex items-center relative cursor-pointer"
|
||||
:class="componentClass"
|
||||
:to="item.to ?? null"
|
||||
:href="item.href ?? null"
|
||||
:target="item.target ?? null"
|
||||
@click="menuClick"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'bg-gray-100 dark:bg-slate-800 lg:bg-transparent lg:dark:bg-transparent p-3 lg:p-0':
|
||||
item.menu,
|
||||
}"
|
||||
>
|
||||
<UserAvatarCurrentUser v-if="item.isCurrentUser" class="w-6 h-6 mr-3 inline-flex" />
|
||||
<BaseIcon v-if="item.icon" :path="item.icon" class="transition-colors" />
|
||||
<span
|
||||
class="px-2 transition-colors"
|
||||
:class="{ 'lg:hidden': item.isDesktopNoLabel && item.icon }"
|
||||
>{{ itemLabel }}</span
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="item.menu"
|
||||
:path="isDropdownActive ? mdiChevronUp : mdiChevronDown"
|
||||
class="hidden lg:inline-flex transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.menu"
|
||||
class="text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-slate-800 dark:border-slate-700"
|
||||
:class="{ 'lg:hidden': !isDropdownActive }"
|
||||
>
|
||||
<NavBarMenuList :menu="item.menu" @menu-click="menuClickDropdown" />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
18
src/components/NavBarItemPlain.vue
Normal file
18
src/components/NavBarItemPlain.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
display: {
|
||||
type: String,
|
||||
default: 'flex',
|
||||
},
|
||||
useMargin: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[display, useMargin ? 'my-2 mx-3' : 'py-2 px-3']"
|
||||
class="navbar-item-label items-center cursor-pointer dark:text-white dark:hover:text-slate-400"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
20
src/components/NavBarMenuList.vue
Normal file
20
src/components/NavBarMenuList.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import NavBarItem from '@/components/NavBarItem.vue'
|
||||
|
||||
defineProps({
|
||||
menu: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
emit('menu-click', event, item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBarItem v-for="(item, index) in menu" :key="index" :item="item" @menu-click="menuClick" />
|
||||
</template>
|
58
src/components/NotificationBar.vue
Normal file
58
src/components/NotificationBar.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed, useSlots } from 'vue'
|
||||
import { mdiClose } from '@mdi/js'
|
||||
import { colorsBgLight, colorsOutline } from '@/colors.js'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
outline: Boolean,
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const componentClass = computed(() =>
|
||||
props.outline ? colorsOutline[props.color] : colorsBgLight[props.color],
|
||||
)
|
||||
|
||||
const isDismissed = ref(false)
|
||||
|
||||
const dismiss = () => {
|
||||
isDismissed.value = true
|
||||
}
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const hasRightSlot = computed(() => slots.right)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isDismissed"
|
||||
:class="componentClass"
|
||||
class="px-3 py-6 md:py-3 mb-6 last:mb-0 border rounded-lg transition-colors duration-150"
|
||||
>
|
||||
<BaseLevel>
|
||||
<div class="flex flex-col md:flex-row items-center">
|
||||
<BaseIcon
|
||||
v-if="icon"
|
||||
:path="icon"
|
||||
w="w-10 md:w-5"
|
||||
h="h-10 md:h-5"
|
||||
size="24"
|
||||
class="md:mr-2"
|
||||
/>
|
||||
<span class="text-center md:text-left md:py-2"><slot /></span>
|
||||
</div>
|
||||
<slot v-if="hasRightSlot" name="right" />
|
||||
<BaseButton v-else :icon="mdiClose" small rounded-full color="white" @click="dismiss" />
|
||||
</BaseLevel>
|
||||
</div>
|
||||
</template>
|
18
src/components/NotificationBarInCard.vue
Normal file
18
src/components/NotificationBarInCard.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { colorsBgLight } from '@/colors.js'
|
||||
|
||||
defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col mb-6 -mt-6 -mr-6 -ml-6 animate-fade-in">
|
||||
<div :class="[colorsBgLight[color]]" class="rounded-t-2xl flex flex-col p-6 transition-colors">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
63
src/components/NumberDynamic.vue
Normal file
63
src/components/NumberDynamic.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import numeral from 'numeral'
|
||||
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
})
|
||||
|
||||
const newValue = ref(0)
|
||||
|
||||
const newValueFormatted = computed(() =>
|
||||
newValue.value < 1000 ? newValue.value : numeral(newValue.value).format('0,0'),
|
||||
)
|
||||
|
||||
const value = computed(() => props.value)
|
||||
|
||||
const grow = (m) => {
|
||||
const v = Math.ceil(newValue.value + m)
|
||||
|
||||
if (v > value.value) {
|
||||
newValue.value = value.value
|
||||
return false
|
||||
}
|
||||
|
||||
newValue.value = v
|
||||
|
||||
setTimeout(() => {
|
||||
grow(m)
|
||||
}, 25)
|
||||
}
|
||||
|
||||
const growInit = () => {
|
||||
newValue.value = 0
|
||||
grow(props.value / (props.duration / 25))
|
||||
}
|
||||
|
||||
watch(value, () => {
|
||||
growInit()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
growInit()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ prefix }}{{ newValueFormatted }}{{ suffix }}</div>
|
||||
</template>
|
47
src/components/OverlayLayer.vue
Normal file
47
src/components/OverlayLayer.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
zIndex: {
|
||||
type: String,
|
||||
default: 'z-50',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'flex',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['overlay-click'])
|
||||
|
||||
const overlayClick = (event) => {
|
||||
emit('overlay-click', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[type, zIndex]"
|
||||
class="items-center flex-col justify-center overflow-hidden fixed inset-0"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="transition duration-150 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
class="overlay absolute inset-0 bg-linear-to-tr opacity-90 dark:from-gray-700 dark:via-gray-900 dark:to-gray-700"
|
||||
@click="overlayClick"
|
||||
/>
|
||||
</transition>
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="animate-fade-out"
|
||||
>
|
||||
<slot />
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
37
src/components/PillTag.vue
Normal file
37
src/components/PillTag.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { colorsBgLight, colorsOutline } from '@/colors.js'
|
||||
import PillTagPlain from '@/components/PillTagPlain.vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
small: Boolean,
|
||||
outline: Boolean,
|
||||
})
|
||||
|
||||
const componentClass = computed(() => [
|
||||
props.small ? 'py-1 px-3' : 'py-1.5 px-4',
|
||||
props.outline ? colorsOutline[props.color] : colorsBgLight[props.color],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PillTagPlain
|
||||
class="border rounded-full"
|
||||
:class="componentClass"
|
||||
:icon="icon"
|
||||
:label="label"
|
||||
:small="small"
|
||||
/>
|
||||
</template>
|
32
src/components/PillTagPlain.vue
Normal file
32
src/components/PillTagPlain.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
small: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center capitalize leading-none"
|
||||
:class="[small ? 'text-xs' : 'text-sm']"
|
||||
>
|
||||
<BaseIcon
|
||||
v-if="icon"
|
||||
:path="icon"
|
||||
h="h-4"
|
||||
w="w-4"
|
||||
:class="small ? 'mr-1' : 'mr-2'"
|
||||
:size="small ? 14 : null"
|
||||
/>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
48
src/components/PillTagTrend.vue
Normal file
48
src/components/PillTagTrend.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { mdiChevronUp, mdiChevronDown, mdiAlertCircleOutline } from '@mdi/js'
|
||||
import PillTag from '@/components/PillTag.vue'
|
||||
|
||||
const props = defineProps({
|
||||
trend: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
trendType: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
small: Boolean,
|
||||
})
|
||||
|
||||
const trendStyle = computed(() => {
|
||||
if (props.trendType === 'up') {
|
||||
return {
|
||||
icon: mdiChevronUp,
|
||||
style: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
if (props.trendType === 'down') {
|
||||
return {
|
||||
icon: mdiChevronDown,
|
||||
style: 'danger',
|
||||
}
|
||||
}
|
||||
|
||||
if (props.trendType === 'alert') {
|
||||
return {
|
||||
icon: mdiAlertCircleOutline,
|
||||
style: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
style: 'info',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PillTag :label="trend" :color="trendStyle.style" :icon="trendStyle.icon" :small="small" />
|
||||
</template>
|
5
src/components/SectionBanner.vue
Normal file
5
src/components/SectionBanner.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-2xl py-12 px-6 lg:px-12 text-center">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
21
src/components/SectionBannerStarOnGitHub.vue
Normal file
21
src/components/SectionBannerStarOnGitHub.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { mdiGithub } from '@mdi/js'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SectionBanner from '@/components/SectionBanner.vue'
|
||||
import { gradientBgPinkRed } from '@/colors'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionBanner :class="gradientBgPinkRed">
|
||||
<h1 class="text-3xl text-white mb-6">Like the project? Please star on <b>GitHub</b> ;-)</h1>
|
||||
<div>
|
||||
<BaseButton
|
||||
href="https://github.com/justboil/admin-one-vue-tailwind"
|
||||
:icon="mdiGithub"
|
||||
label="GitHub"
|
||||
target="_blank"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</SectionBanner>
|
||||
</template>
|
34
src/components/SectionFullScreen.vue
Normal file
34
src/components/SectionFullScreen.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDarkModeStore } from '@/stores/darkMode.js'
|
||||
import { gradientBgPurplePink, gradientBgDark, gradientBgPinkRed } from '@/colors.js'
|
||||
|
||||
const props = defineProps({
|
||||
bg: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['purplePink', 'pinkRed'].includes(value),
|
||||
},
|
||||
})
|
||||
|
||||
const colorClass = computed(() => {
|
||||
if (useDarkModeStore().isEnabled) {
|
||||
return gradientBgDark
|
||||
}
|
||||
|
||||
switch (props.bg) {
|
||||
case 'purplePink':
|
||||
return gradientBgPurplePink
|
||||
case 'pinkRed':
|
||||
return gradientBgPinkRed
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center" :class="colorClass">
|
||||
<slot card-class="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12 shadow-2xl" />
|
||||
</div>
|
||||
</template>
|
9
src/components/SectionMain.vue
Normal file
9
src/components/SectionMain.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import { containerMaxW } from '@/config.js'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="p-6" :class="containerMaxW">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
19
src/components/SectionTitle.vue
Normal file
19
src/components/SectionTitle.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
custom: Boolean,
|
||||
first: Boolean,
|
||||
last: Boolean,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="py-24 px-6 lg:px-0 lg:max-w-2xl lg:mx-auto text-center"
|
||||
:class="{ '-mb-6': first, '-mt-6': last, '-my-6': !first && !last }"
|
||||
>
|
||||
<slot v-if="custom" />
|
||||
<h1 v-else class="text-2xl text-gray-500 dark:text-slate-400">
|
||||
<slot />
|
||||
</h1>
|
||||
</section>
|
||||
</template>
|
35
src/components/SectionTitleLineWithButton.vue
Normal file
35
src/components/SectionTitleLineWithButton.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { mdiCog } from '@mdi/js'
|
||||
import { useSlots, computed } from 'vue'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import IconRounded from '@/components/IconRounded.vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
main: Boolean,
|
||||
})
|
||||
|
||||
const hasSlot = computed(() => useSlots().default)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="{ 'pt-6': !main }" class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start">
|
||||
<IconRounded v-if="icon && main" :icon="icon" color="light" class="mr-3" bg />
|
||||
<BaseIcon v-else-if="icon" :path="icon" class="mr-2" size="20" />
|
||||
<h1 :class="main ? 'text-3xl' : 'text-2xl'" class="leading-tight">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<slot v-if="hasSlot" />
|
||||
<BaseButton v-else :icon="mdiCog" color="whiteDark" />
|
||||
</section>
|
||||
</template>
|
27
src/components/TableCheckboxCell.vue
Normal file
27
src/components/TableCheckboxCell.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'td',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['checked'])
|
||||
|
||||
const checked = ref(false)
|
||||
|
||||
watch(checked, (newVal) => {
|
||||
emit('checked', newVal)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="type" class="lg:w-1">
|
||||
<label class="checkbox">
|
||||
<input v-model="checked" type="checkbox" />
|
||||
<span class="check" />
|
||||
</label>
|
||||
</component>
|
||||
</template>
|
148
src/components/TableSampleClients.vue
Normal file
148
src/components/TableSampleClients.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { mdiEye, mdiTrashCan } from '@mdi/js'
|
||||
import CardBoxModal from '@/components/CardBoxModal.vue'
|
||||
import TableCheckboxCell from '@/components/TableCheckboxCell.vue'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import BaseButtons from '@/components/BaseButtons.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
defineProps({
|
||||
checkable: Boolean,
|
||||
})
|
||||
|
||||
const mainStore = useMainStore()
|
||||
|
||||
const items = computed(() => mainStore.clients)
|
||||
|
||||
const isModalActive = ref(false)
|
||||
|
||||
const isModalDangerActive = ref(false)
|
||||
|
||||
const perPage = ref(5)
|
||||
|
||||
const currentPage = ref(0)
|
||||
|
||||
const checkedRows = ref([])
|
||||
|
||||
const itemsPaginated = computed(() =>
|
||||
items.value.slice(perPage.value * currentPage.value, perPage.value * (currentPage.value + 1)),
|
||||
)
|
||||
|
||||
const numPages = computed(() => Math.ceil(items.value.length / perPage.value))
|
||||
|
||||
const currentPageHuman = computed(() => currentPage.value + 1)
|
||||
|
||||
const pagesList = computed(() => {
|
||||
const pagesList = []
|
||||
|
||||
for (let i = 0; i < numPages.value; i++) {
|
||||
pagesList.push(i)
|
||||
}
|
||||
|
||||
return pagesList
|
||||
})
|
||||
|
||||
const remove = (arr, cb) => {
|
||||
const newArr = []
|
||||
|
||||
arr.forEach((item) => {
|
||||
if (!cb(item)) {
|
||||
newArr.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
return newArr
|
||||
}
|
||||
|
||||
const checked = (isChecked, client) => {
|
||||
if (isChecked) {
|
||||
checkedRows.value.push(client)
|
||||
} else {
|
||||
checkedRows.value = remove(checkedRows.value, (row) => row.id === client.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardBoxModal v-model="isModalActive" title="Sample modal">
|
||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
||||
<p>This is sample modal</p>
|
||||
</CardBoxModal>
|
||||
|
||||
<CardBoxModal v-model="isModalDangerActive" title="Please confirm" button="danger" has-cancel>
|
||||
<p>Lorem ipsum dolor sit amet <b>adipiscing elit</b></p>
|
||||
<p>This is sample modal</p>
|
||||
</CardBoxModal>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="checkable" />
|
||||
<th />
|
||||
<th>Name</th>
|
||||
<th>Company</th>
|
||||
<th>City</th>
|
||||
<th>Progress</th>
|
||||
<th>Created</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="client in itemsPaginated" :key="client.id">
|
||||
<TableCheckboxCell v-if="checkable" @checked="checked($event, client)" />
|
||||
<td class="border-b-0 lg:w-6 before:hidden">
|
||||
<UserAvatar :username="client.name" class="w-24 h-24 mx-auto lg:w-6 lg:h-6" />
|
||||
</td>
|
||||
<td data-label="Name">
|
||||
{{ client.name }}
|
||||
</td>
|
||||
<td data-label="Company">
|
||||
{{ client.company }}
|
||||
</td>
|
||||
<td data-label="City">
|
||||
{{ client.city }}
|
||||
</td>
|
||||
<td data-label="Progress" class="lg:w-32">
|
||||
<progress class="flex w-2/5 self-center lg:w-full" max="100" :value="client.progress">
|
||||
{{ client.progress }}
|
||||
</progress>
|
||||
</td>
|
||||
<td data-label="Created" class="lg:w-1 whitespace-nowrap">
|
||||
<small class="text-gray-500 dark:text-slate-400" :title="client.created">{{
|
||||
client.created
|
||||
}}</small>
|
||||
</td>
|
||||
<td class="before:hidden lg:w-1 whitespace-nowrap">
|
||||
<BaseButtons type="justify-start lg:justify-end" no-wrap>
|
||||
<BaseButton color="info" :icon="mdiEye" small @click="isModalActive = true" />
|
||||
<BaseButton
|
||||
color="danger"
|
||||
:icon="mdiTrashCan"
|
||||
small
|
||||
@click="isModalDangerActive = true"
|
||||
/>
|
||||
</BaseButtons>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800">
|
||||
<BaseLevel>
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
v-for="page in pagesList"
|
||||
:key="page"
|
||||
:active="page === currentPage"
|
||||
:label="page + 1"
|
||||
:color="page === currentPage ? 'lightDark' : 'whiteDark'"
|
||||
small
|
||||
@click="currentPage = page"
|
||||
/>
|
||||
</BaseButtons>
|
||||
<small>Page {{ currentPageHuman }} of {{ numPages }}</small>
|
||||
</BaseLevel>
|
||||
</div>
|
||||
</template>
|
40
src/components/UserAvatar.vue
Normal file
40
src/components/UserAvatar.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
api: {
|
||||
type: String,
|
||||
default: 'avataaars',
|
||||
},
|
||||
})
|
||||
|
||||
const avatar = computed(
|
||||
() =>
|
||||
props.avatar ??
|
||||
`https://api.dicebear.com/7.x/${props.api}/svg?seed=${props.username.replace(
|
||||
/[^a-z0-9]+/gi,
|
||||
'-',
|
||||
)}.svg`,
|
||||
)
|
||||
|
||||
const username = computed(() => props.username)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<img
|
||||
:src="avatar"
|
||||
:alt="username"
|
||||
class="rounded-full block h-auto w-full max-w-full bg-gray-100 dark:bg-slate-800"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
12
src/components/UserAvatarCurrentUser.vue
Normal file
12
src/components/UserAvatarCurrentUser.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const mainStore = useMainStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar :username="mainStore.userName" :avatar="mainStore.userAvatar">
|
||||
<slot />
|
||||
</UserAvatar>
|
||||
</template>
|
43
src/components/UserCard.vue
Normal file
43
src/components/UserCard.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { mdiCheckDecagram } from '@mdi/js'
|
||||
import BaseLevel from '@/components/BaseLevel.vue'
|
||||
import UserAvatarCurrentUser from '@/components/UserAvatarCurrentUser.vue'
|
||||
import CardBox from '@/components/CardBox.vue'
|
||||
import FormCheckRadio from '@/components/FormCheckRadio.vue'
|
||||
import PillTag from '@/components/PillTag.vue'
|
||||
|
||||
const mainStore = useMainStore()
|
||||
|
||||
const userName = computed(() => mainStore.userName)
|
||||
|
||||
const userSwitchVal = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardBox>
|
||||
<BaseLevel type="justify-around lg:justify-center">
|
||||
<UserAvatarCurrentUser class="lg:mx-12" />
|
||||
<div class="space-y-3 text-center md:text-left lg:mx-12">
|
||||
<div class="flex justify-center md:block">
|
||||
<FormCheckRadio
|
||||
v-model="userSwitchVal"
|
||||
name="notifications-switch"
|
||||
type="switch"
|
||||
label="Notifications"
|
||||
:input-value="true"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-2xl">
|
||||
Howdy, <b>{{ userName }}</b
|
||||
>!
|
||||
</h1>
|
||||
<p>Last login <b>12 mins ago</b> from <b>127.0.0.1</b></p>
|
||||
<div class="flex justify-center md:block">
|
||||
<PillTag label="Verified" color="info" :icon="mdiCheckDecagram" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLevel>
|
||||
</CardBox>
|
||||
</template>
|
1
src/config.js
Normal file
1
src/config.js
Normal file
@ -0,0 +1 @@
|
||||
export const containerMaxW = 'xl:max-w-6xl xl:mx-auto'
|
205
src/css/_checkbox-radio-switch.css
Normal file
205
src/css/_checkbox-radio-switch.css
Normal file
@ -0,0 +1,205 @@
|
||||
@utility checkbox {
|
||||
@apply inline-flex items-center cursor-pointer relative;
|
||||
|
||||
& input[type='checkbox'] {
|
||||
@apply absolute left-0 opacity-0 -z-1;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:focus + .check {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply block w-5 h-5;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:checked + .check {
|
||||
@apply bg-no-repeat bg-center border-4;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:checked + .check {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
& input[type='checkbox']:checked + .check {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility radio {
|
||||
@apply inline-flex items-center cursor-pointer relative;
|
||||
|
||||
& input[type='radio'] {
|
||||
@apply absolute left-0 opacity-0 -z-1;
|
||||
}
|
||||
|
||||
& input[type='radio'] + .check {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
& input[type='radio']:focus + .check {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
& input[type='radio'] + .check {
|
||||
@apply block w-5 h-5;
|
||||
}
|
||||
|
||||
& input[type='radio'] + .check {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
& input[type='radio']:checked + .check {
|
||||
@apply bg-no-repeat bg-center border-4;
|
||||
}
|
||||
|
||||
& input[type='radio']:checked + .check {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
& input[type='radio']:checked + .check {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility switch {
|
||||
@apply inline-flex items-center cursor-pointer relative;
|
||||
|
||||
& input[type='checkbox'] {
|
||||
@apply absolute left-0 opacity-0 -z-1;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:focus + .check {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply flex items-center shrink-0 w-12 h-6 p-0.5 bg-gray-200;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check:before {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:checked + .check {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
|
||||
& input[type='checkbox'] + .check:before {
|
||||
content: '';
|
||||
@apply block w-5 h-5 bg-white border border-gray-700;
|
||||
}
|
||||
|
||||
& input[type='checkbox']:checked + .check:before {
|
||||
transform: translate3d(110%, 0, 0);
|
||||
@apply border-blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@utility check {
|
||||
.checkbox input[type='checkbox'] + & {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
.radio input[type='radio'] + & {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox'] + & {
|
||||
@apply border-gray-700 border transition-colors duration-200 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox']:focus + & {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
.radio input[type='radio']:focus + & {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox']:focus + & {
|
||||
@apply ring-3 ring-blue-700;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox'] + & {
|
||||
@apply block w-5 h-5;
|
||||
}
|
||||
|
||||
.radio input[type='radio'] + & {
|
||||
@apply block w-5 h-5;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox'] + & {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox'] + & {
|
||||
@apply flex items-center shrink-0 w-12 h-6 p-0.5 bg-gray-200;
|
||||
}
|
||||
|
||||
.radio input[type='radio'] + & {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox'] + & {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox'] + &:before {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox']:checked + & {
|
||||
@apply bg-no-repeat bg-center border-4;
|
||||
}
|
||||
|
||||
.radio input[type='radio']:checked + & {
|
||||
@apply bg-no-repeat bg-center border-4;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox']:checked + & {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.radio input[type='radio']:checked + & {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z' /%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.switch input[type='checkbox']:checked + & {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
|
||||
.checkbox input[type='checkbox']:checked + & {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
|
||||
.radio input[type='radio']:checked + & {
|
||||
@apply bg-blue-600 border-blue-600;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox'] + &:before {
|
||||
content: '';
|
||||
@apply block w-5 h-5 bg-white border border-gray-700;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox']:checked + &:before {
|
||||
transform: translate3d(110%, 0, 0);
|
||||
@apply border-blue-600;
|
||||
}
|
||||
}
|
21
src/css/_progress.css
Normal file
21
src/css/_progress.css
Normal file
@ -0,0 +1,21 @@
|
||||
@layer base {
|
||||
progress {
|
||||
@apply h-3 rounded-full overflow-hidden;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
@apply bg-blue-200;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
progress::-moz-progress-bar {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
progress::-ms-fill {
|
||||
@apply bg-blue-500 border-0;
|
||||
}
|
||||
}
|
41
src/css/_scrollbars.css
Normal file
41
src/css/_scrollbars.css
Normal file
@ -0,0 +1,41 @@
|
||||
@utility dark-scrollbars-compat {
|
||||
scrollbar-color: rgb(71, 85, 105) rgb(30, 41, 59);
|
||||
}
|
||||
|
||||
@utility dark-scrollbars {
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply bg-slate-800;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-600;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-slate-500;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(156, 163, 175) rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-track {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-400 rounded-sm;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500;
|
||||
}
|
||||
}
|
49
src/css/_table.css
Normal file
49
src/css/_table.css
Normal file
@ -0,0 +1,49 @@
|
||||
@layer base {
|
||||
table {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
thead {
|
||||
@apply hidden lg:table-header-group;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply max-w-full block relative border-b-4 border-gray-100
|
||||
lg:table-row lg:border-b-0 dark:border-slate-800;
|
||||
}
|
||||
|
||||
tr:last-child {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
td:not(:first-child) {
|
||||
@apply lg:border-l lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-gray-100 lg:dark:border-slate-700;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply lg:text-left lg:p-3;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply flex justify-between text-right py-3 px-4 align-top border-b border-gray-100
|
||||
lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
tbody tr,
|
||||
tbody tr:nth-child(odd) {
|
||||
@apply lg:hover:bg-gray-100 lg:dark:hover:bg-slate-700/70;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
@apply lg:bg-gray-100/50 lg:dark:bg-slate-800/50;
|
||||
}
|
||||
|
||||
td:before {
|
||||
content: attr(data-label);
|
||||
@apply font-semibold pr-3 text-left lg:hidden;
|
||||
}
|
||||
}
|
13
src/css/main.css
Normal file
13
src/css/main.css
Normal file
@ -0,0 +1,13 @@
|
||||
@import './tailwind/_base.css';
|
||||
@import './tailwind/_components.css';
|
||||
@import './tailwind/_utilities.css';
|
||||
|
||||
@import './_checkbox-radio-switch.css';
|
||||
@import './_progress.css';
|
||||
@import './_scrollbars.css';
|
||||
@import './_table.css';
|
||||
|
||||
@import './styles/_basic.css';
|
||||
@import './styles/_white.css';
|
||||
|
||||
@config '../../tailwind.config.js';
|
31
src/css/styles/_basic.css
Normal file
31
src/css/styles/_basic.css
Normal file
@ -0,0 +1,31 @@
|
||||
@utility style-basic {
|
||||
&:not(.dark) {
|
||||
.aside {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
.aside-scrollbars {
|
||||
@apply aside-scrollbars-gray;
|
||||
}
|
||||
.aside-brand {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
.aside-menu-item {
|
||||
@apply text-gray-300 hover:text-white;
|
||||
}
|
||||
.aside-menu-item-active {
|
||||
@apply text-white;
|
||||
}
|
||||
.aside-menu-dropdown {
|
||||
@apply bg-gray-700/50;
|
||||
}
|
||||
.navbar-item-label {
|
||||
@apply text-black hover:text-blue-500;
|
||||
}
|
||||
.navbar-item-label-active {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
.overlay {
|
||||
@apply from-gray-700 via-gray-900 to-gray-700;
|
||||
}
|
||||
}
|
||||
}
|
31
src/css/styles/_white.css
Normal file
31
src/css/styles/_white.css
Normal file
@ -0,0 +1,31 @@
|
||||
@utility style-white {
|
||||
&:not(.dark) {
|
||||
.aside {
|
||||
@apply bg-white;
|
||||
}
|
||||
.aside-scrollbars {
|
||||
@apply aside-scrollbars-light;
|
||||
}
|
||||
.aside-menu-item {
|
||||
@apply text-blue-600 hover:text-black;
|
||||
}
|
||||
.aside-menu-item-active {
|
||||
@apply text-black;
|
||||
}
|
||||
.aside-menu-dropdown {
|
||||
@apply bg-gray-100/75;
|
||||
}
|
||||
.navbar-item-label {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
.navbar-item-label-hover {
|
||||
@apply hover:text-black;
|
||||
}
|
||||
.navbar-item-label-active {
|
||||
@apply text-black;
|
||||
}
|
||||
.overlay {
|
||||
@apply from-white via-gray-100 to-white;
|
||||
}
|
||||
}
|
||||
}
|
20
src/css/tailwind/_base.css
Normal file
20
src/css/tailwind/_base.css
Normal file
@ -0,0 +1,20 @@
|
||||
@import 'tailwindcss/theme' layer(theme);
|
||||
@import 'tailwindcss/preflight' layer(base);
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
0
src/css/tailwind/_components.css
Normal file
0
src/css/tailwind/_components.css
Normal file
1
src/css/tailwind/_utilities.css
Normal file
1
src/css/tailwind/_utilities.css
Normal file
@ -0,0 +1 @@
|
||||
@import 'tailwindcss/utilities' layer(utilities);
|
84
src/layouts/LayoutAuthenticated.vue
Normal file
84
src/layouts/LayoutAuthenticated.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import menuAside from '@/menuAside.js'
|
||||
import menuNavBar from '@/menuNavBar.js'
|
||||
import { useDarkModeStore } from '@/stores/darkMode.js'
|
||||
import BaseIcon from '@/components/BaseIcon.vue'
|
||||
import FormControl from '@/components/FormControl.vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import NavBarItemPlain from '@/components/NavBarItemPlain.vue'
|
||||
import AsideMenu from '@/components/AsideMenu.vue'
|
||||
import FooterBar from '@/components/FooterBar.vue'
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
|
||||
const darkModeStore = useDarkModeStore()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const isAsideMobileExpanded = ref(false)
|
||||
const isAsideLgActive = ref(false)
|
||||
|
||||
router.beforeEach(() => {
|
||||
isAsideMobileExpanded.value = false
|
||||
isAsideLgActive.value = false
|
||||
})
|
||||
|
||||
const menuClick = (event, item) => {
|
||||
if (item.isToggleLightDark) {
|
||||
darkModeStore.set()
|
||||
}
|
||||
|
||||
if (item.isLogout) {
|
||||
//
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'overflow-hidden lg:overflow-visible': isAsideMobileExpanded,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="[layoutAsidePadding, { 'ml-60 lg:ml-0': isAsideMobileExpanded }]"
|
||||
class="pt-14 min-h-screen w-screen transition-position lg:w-auto bg-gray-50 dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<NavBar
|
||||
:menu="menuNavBar"
|
||||
:class="[layoutAsidePadding, { 'ml-60 lg:ml-0': isAsideMobileExpanded }]"
|
||||
@menu-click="menuClick"
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
@click.prevent="isAsideMobileExpanded = !isAsideMobileExpanded"
|
||||
>
|
||||
<BaseIcon :path="isAsideMobileExpanded ? mdiBackburger : mdiForwardburger" size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain display="hidden lg:flex xl:hidden" @click.prevent="isAsideLgActive = true">
|
||||
<BaseIcon :path="mdiMenu" size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain use-margin>
|
||||
<FormControl placeholder="Search (ctrl+k)" ctrl-k-focus transparent borderless />
|
||||
</NavBarItemPlain>
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
:is-aside-mobile-expanded="isAsideMobileExpanded"
|
||||
:is-aside-lg-active="isAsideLgActive"
|
||||
:menu="menuAside"
|
||||
@menu-click="menuClick"
|
||||
@aside-lg-close-click="isAsideLgActive = false"
|
||||
/>
|
||||
<slot />
|
||||
<FooterBar>
|
||||
Get more with
|
||||
<a href="https://tailwind-vue.justboil.me/" target="_blank" class="text-blue-600"
|
||||
>Premium version</a
|
||||
>
|
||||
</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
5
src/layouts/LayoutGuest.vue
Normal file
5
src/layouts/LayoutGuest.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-gray-50 dark:bg-slate-800 dark:text-slate-100">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
44
src/main.js
Normal file
44
src/main.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useMainStore } from '@/stores/main.js'
|
||||
|
||||
import './css/main.css'
|
||||
|
||||
// Init Pinia
|
||||
const pinia = createPinia()
|
||||
|
||||
// Create Vue app
|
||||
createApp(App).use(router).use(pinia).mount('#app')
|
||||
|
||||
// Init main store
|
||||
const mainStore = useMainStore(pinia)
|
||||
|
||||
// Fetch sample data
|
||||
mainStore.fetchSampleClients()
|
||||
mainStore.fetchSampleHistory()
|
||||
|
||||
// Dark mode
|
||||
// Uncomment, if you'd like to restore persisted darkMode setting, or use `prefers-color-scheme: dark`. Make sure to uncomment localStorage block in src/stores/darkMode.js
|
||||
// import { useDarkModeStore } from './stores/darkMode'
|
||||
|
||||
// const darkModeStore = useDarkModeStore(pinia)
|
||||
|
||||
// if (
|
||||
// (!localStorage['darkMode'] && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
// localStorage['darkMode'] === '1'
|
||||
// ) {
|
||||
// darkModeStore.set(true)
|
||||
// }
|
||||
|
||||
// Default title tag
|
||||
const defaultDocumentTitle = 'Admin One Vue 3 Tailwind'
|
||||
|
||||
// Set document title from route meta
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta?.title
|
||||
? `${to.meta.title} — ${defaultDocumentTitle}`
|
||||
: defaultDocumentTitle
|
||||
})
|
86
src/menuAside.js
Normal file
86
src/menuAside.js
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
mdiAccountCircle,
|
||||
mdiMonitor,
|
||||
mdiGithub,
|
||||
mdiLock,
|
||||
mdiAlertCircle,
|
||||
mdiSquareEditOutline,
|
||||
mdiTable,
|
||||
mdiViewList,
|
||||
mdiTelevisionGuide,
|
||||
mdiResponsive,
|
||||
mdiPalette,
|
||||
mdiReact,
|
||||
} from '@mdi/js'
|
||||
|
||||
export default [
|
||||
{
|
||||
to: '/dashboard',
|
||||
icon: mdiMonitor,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
to: '/tables',
|
||||
label: 'Tables',
|
||||
icon: mdiTable,
|
||||
},
|
||||
{
|
||||
to: '/forms',
|
||||
label: 'Forms',
|
||||
icon: mdiSquareEditOutline,
|
||||
},
|
||||
{
|
||||
to: '/ui',
|
||||
label: 'UI',
|
||||
icon: mdiTelevisionGuide,
|
||||
},
|
||||
{
|
||||
to: '/responsive',
|
||||
label: 'Responsive',
|
||||
icon: mdiResponsive,
|
||||
},
|
||||
{
|
||||
to: '/',
|
||||
label: 'Styles',
|
||||
icon: mdiPalette,
|
||||
},
|
||||
{
|
||||
to: '/profile',
|
||||
label: 'Profile',
|
||||
icon: mdiAccountCircle,
|
||||
},
|
||||
{
|
||||
to: '/login',
|
||||
label: 'Login',
|
||||
icon: mdiLock,
|
||||
},
|
||||
{
|
||||
to: '/error',
|
||||
label: 'Error',
|
||||
icon: mdiAlertCircle,
|
||||
},
|
||||
{
|
||||
label: 'Dropdown',
|
||||
icon: mdiViewList,
|
||||
menu: [
|
||||
{
|
||||
label: 'Item One',
|
||||
},
|
||||
{
|
||||
label: 'Item Two',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/justboil/admin-one-vue-tailwind',
|
||||
label: 'GitHub',
|
||||
icon: mdiGithub,
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/justboil/admin-one-react-tailwind',
|
||||
label: 'React version',
|
||||
icon: mdiReact,
|
||||
target: '_blank',
|
||||
},
|
||||
]
|
89
src/menuNavBar.js
Normal file
89
src/menuNavBar.js
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
mdiMenu,
|
||||
mdiClockOutline,
|
||||
mdiCloud,
|
||||
mdiCrop,
|
||||
mdiAccount,
|
||||
mdiCogOutline,
|
||||
mdiEmail,
|
||||
mdiLogout,
|
||||
mdiThemeLightDark,
|
||||
mdiGithub,
|
||||
mdiReact,
|
||||
} from '@mdi/js'
|
||||
|
||||
export default [
|
||||
{
|
||||
icon: mdiMenu,
|
||||
label: 'Sample menu',
|
||||
menu: [
|
||||
{
|
||||
icon: mdiClockOutline,
|
||||
label: 'Item One',
|
||||
},
|
||||
{
|
||||
icon: mdiCloud,
|
||||
label: 'Item Two',
|
||||
},
|
||||
{
|
||||
isDivider: true,
|
||||
},
|
||||
{
|
||||
icon: mdiCrop,
|
||||
label: 'Item Last',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
isCurrentUser: true,
|
||||
menu: [
|
||||
{
|
||||
icon: mdiAccount,
|
||||
label: 'My Profile',
|
||||
to: '/profile',
|
||||
},
|
||||
{
|
||||
icon: mdiCogOutline,
|
||||
label: 'Settings',
|
||||
},
|
||||
{
|
||||
icon: mdiEmail,
|
||||
label: 'Messages',
|
||||
},
|
||||
{
|
||||
isDivider: true,
|
||||
},
|
||||
{
|
||||
icon: mdiLogout,
|
||||
label: 'Log Out',
|
||||
isLogout: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: mdiThemeLightDark,
|
||||
label: 'Light/Dark',
|
||||
isDesktopNoLabel: true,
|
||||
isToggleLightDark: true,
|
||||
},
|
||||
{
|
||||
icon: mdiGithub,
|
||||
label: 'GitHub',
|
||||
isDesktopNoLabel: true,
|
||||
href: 'https://github.com/justboil/admin-one-vue-tailwind',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
icon: mdiReact,
|
||||
label: 'React version',
|
||||
isDesktopNoLabel: true,
|
||||
href: 'https://github.com/justboil/admin-one-react-tailwind',
|
||||
target: '_blank',
|
||||
},
|
||||
{
|
||||
icon: mdiLogout,
|
||||
label: 'Log out',
|
||||
isDesktopNoLabel: true,
|
||||
isLogout: true,
|
||||
},
|
||||
]
|
90
src/router/index.js
Normal file
90
src/router/index.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import Style from '@/views/StyleView.vue'
|
||||
import Home from '@/views/HomeView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
meta: {
|
||||
title: 'Select style',
|
||||
},
|
||||
path: '/',
|
||||
name: 'style',
|
||||
component: Style,
|
||||
},
|
||||
{
|
||||
// Document title tag
|
||||
// We combine it with defaultDocumentTitle set in `src/main.js` on router.afterEach hook
|
||||
meta: {
|
||||
title: 'Dashboard',
|
||||
},
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Tables',
|
||||
},
|
||||
path: '/tables',
|
||||
name: 'tables',
|
||||
component: () => import('@/views/TablesView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Forms',
|
||||
},
|
||||
path: '/forms',
|
||||
name: 'forms',
|
||||
component: () => import('@/views/FormsView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Profile',
|
||||
},
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/ProfileView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Ui',
|
||||
},
|
||||
path: '/ui',
|
||||
name: 'ui',
|
||||
component: () => import('@/views/UiView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Responsive layout',
|
||||
},
|
||||
path: '/responsive',
|
||||
name: 'responsive',
|
||||
component: () => import('@/views/ResponsiveView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Login',
|
||||
},
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
title: 'Error',
|
||||
},
|
||||
path: '/error',
|
||||
name: 'error',
|
||||
component: () => import('@/views/ErrorView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
export default router
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user