Initial Commit, final submission
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 160,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
2665
package-lock.json
generated
Normal file
2665
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "cs343-project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.3",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"daisyui": "^4.12.14",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"prettier": "3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^5.4.9"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"idb": "^8.0.0",
|
||||||
|
"ky": "^1.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
19
src/app.d.ts
vendored
Normal file
19
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
declare global {
|
||||||
|
type Product = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
seller: string
|
||||||
|
modelNumber: string
|
||||||
|
price: {
|
||||||
|
amount: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
inStock: boolean
|
||||||
|
stockAmount: number
|
||||||
|
imageUrl: URL
|
||||||
|
releaseDate: Date
|
||||||
|
distributer: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
BIN
src/images/albumcollage.png
Normal file
BIN
src/images/albumcollage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 MiB |
BIN
src/images/tanocStoreLogo.png
Normal file
BIN
src/images/tanocStoreLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
59
src/lib/components/alertBox.ts
Normal file
59
src/lib/components/alertBox.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
interface AlertDetail {
|
||||||
|
type: 'alert-info' | 'alert-warning' | 'alert-success' | 'alert-error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlertEvent extends CustomEvent<AlertDetail> {
|
||||||
|
constructor(detail: AlertDetail) {
|
||||||
|
super('alert', { detail })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertTemplate = document.createElement('template')
|
||||||
|
alertTemplate.innerHTML = `
|
||||||
|
<div class="alert">
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
class Alert extends HTMLElement {
|
||||||
|
constructor(alertDetail: AlertDetail) {
|
||||||
|
super()
|
||||||
|
this.appendChild(alertTemplate.content.cloneNode(true))
|
||||||
|
|
||||||
|
const outer = this.querySelector<HTMLDivElement>('div')!
|
||||||
|
outer.classList.add(alertDetail.type)
|
||||||
|
|
||||||
|
const message = this.querySelector<HTMLSpanElement>('span')!
|
||||||
|
message.textContent = alertDetail.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertBoxTemplate = document.createElement('template')
|
||||||
|
alertBoxTemplate.innerHTML = `
|
||||||
|
<div id="alert-box" class="toast toast-top toast-end"></div>
|
||||||
|
`
|
||||||
|
|
||||||
|
class AlertBox extends HTMLElement {
|
||||||
|
private readonly alertBox: HTMLDivElement
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.appendChild(alertBoxTemplate.content.cloneNode(true))
|
||||||
|
this.alertBox = this.querySelector<HTMLDivElement>('#alert-box')!
|
||||||
|
}
|
||||||
|
|
||||||
|
public add = (alertDetail: AlertDetail) => {
|
||||||
|
const alert = new Alert(alertDetail)
|
||||||
|
this.alertBox.appendChild(alert)
|
||||||
|
setTimeout(() => alert.remove(), 7000)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('alert-element', Alert)
|
||||||
|
customElements.define('alert-box', AlertBox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlertBox
|
||||||
|
export { AlertEvent, type AlertDetail }
|
||||||
119
src/lib/components/currencySelector.ts
Normal file
119
src/lib/components/currencySelector.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import CurrencyManager, { type Currency } from '@lib/currency'
|
||||||
|
|
||||||
|
const currencyOptionTemplate = document.createElement('template')
|
||||||
|
currencyOptionTemplate.innerHTML = `
|
||||||
|
<li>
|
||||||
|
<button>
|
||||||
|
<span id="badge" class="badge badge-sm badge-outline font-mono !text-[.6rem] font-bold tracking-widest opacity-50"></span>
|
||||||
|
<span id="currency-name" class="text-nowrap line-clamp-1 overflow-ellipsis text-sm font-sans text-neutral-content"></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`
|
||||||
|
|
||||||
|
class CurrencyOption extends HTMLElement {
|
||||||
|
public readonly currency: Currency
|
||||||
|
private readonly optionButton: HTMLButtonElement
|
||||||
|
|
||||||
|
constructor(currency: Currency) {
|
||||||
|
super()
|
||||||
|
this.appendChild(currencyOptionTemplate.content.cloneNode(true))
|
||||||
|
|
||||||
|
this.currency = currency
|
||||||
|
this.optionButton = this.querySelector<HTMLButtonElement>('button')!
|
||||||
|
|
||||||
|
this.querySelector<HTMLSpanElement>('#badge')!.innerText = this.currency.code
|
||||||
|
this.querySelector<HTMLSpanElement>('#currency-name')!.innerText = this.currency.name
|
||||||
|
}
|
||||||
|
|
||||||
|
public set active(active: boolean) {
|
||||||
|
active ? this.optionButton.classList.add('active') : this.optionButton.classList.remove('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
public set onclick(handler: GlobalEventHandlers['onclick']) {
|
||||||
|
this.optionButton.onclick = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrencyChangeEventDetail {
|
||||||
|
newCurrencyCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrencyChangeEvent extends CustomEvent<CurrencyChangeEventDetail> {
|
||||||
|
constructor(detail: CurrencyChangeEventDetail) {
|
||||||
|
super('currencyChanged', { detail })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencySelectorTemplate = document.createElement('template')
|
||||||
|
currencySelectorTemplate.innerHTML = `
|
||||||
|
<div class="dropdown dropdown-bottom dropdown-end w-full">
|
||||||
|
<button class="btn btn-ghost flex-nowrap w-full">
|
||||||
|
<span id="active-currency" class="opacity-60"></span>
|
||||||
|
<svg
|
||||||
|
width="12px"
|
||||||
|
height="12px"
|
||||||
|
class="hidden h-2 w-2 fill-current opacity-60 sm:inline-block"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 2048 2048"
|
||||||
|
>
|
||||||
|
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-content menu menu-sm bg-base-200 rounded-box border-neutral my-2 w-64 border p-2 shadow">
|
||||||
|
<ul id="options" class="max-h-96 overflow-y-scroll flex flex-col gap-1"></ul>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
class CurrencySelector extends HTMLElement {
|
||||||
|
private readonly currencyOptions: Map<string, CurrencyOption>
|
||||||
|
public oncurrencychange: ((event: CurrencyChangeEvent) => any) | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.currencyOptions = new Map()
|
||||||
|
this.appendChild(currencySelectorTemplate.content.cloneNode(true))
|
||||||
|
const currencyOptionList = this.querySelector<HTMLUListElement>('#options')!
|
||||||
|
CurrencyManager.getEnabledCurrencies().then((currencies) => {
|
||||||
|
currencies.forEach((currency) => {
|
||||||
|
const option = this.addCurrencyOption(currency)
|
||||||
|
currencyOptionList.appendChild(option)
|
||||||
|
})
|
||||||
|
this.updateActiveCurrency(CurrencyManager.activeCurrency)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private addCurrencyOption(currency: Currency): CurrencyOption {
|
||||||
|
const option = new CurrencyOption(currency)
|
||||||
|
option.onclick = () => this.updateActiveCurrency(currency.code)
|
||||||
|
option.active = currency.code === CurrencyManager.activeCurrency
|
||||||
|
this.currencyOptions.set(currency.code, option)
|
||||||
|
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateActiveCurrency(currencyCode: string) {
|
||||||
|
const newOption = this.currencyOptions.get(currencyCode)
|
||||||
|
|
||||||
|
if (!newOption) return
|
||||||
|
|
||||||
|
this.currencyOptions.forEach((option) => (option.active = false))
|
||||||
|
|
||||||
|
CurrencyManager.activeCurrency = currencyCode
|
||||||
|
|
||||||
|
newOption.active = true
|
||||||
|
|
||||||
|
const activeCurrency = this.querySelector<HTMLSpanElement>('#active-currency')!
|
||||||
|
activeCurrency.innerText = currencyCode
|
||||||
|
|
||||||
|
if (this.oncurrencychange) {
|
||||||
|
this.oncurrencychange(new CurrencyChangeEvent({ newCurrencyCode: currencyCode }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('currency-option', CurrencyOption)
|
||||||
|
customElements.define('currency-selector', CurrencySelector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrencySelector
|
||||||
96
src/lib/components/currencyTable.ts
Normal file
96
src/lib/components/currencyTable.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import CurrencyManager, { type CurrencyDBEntry } from '@lib/currency'
|
||||||
|
|
||||||
|
const tableRowTemplate = document.createElement('template')
|
||||||
|
tableRowTemplate.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span id="code" class="badge badge-sm badge-outline font-mono !text-[.6rem] font-bold tracking-widest opacity-50"></span>
|
||||||
|
</td>
|
||||||
|
<td id="name"></td>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="toggle toggle-sm toggle-accent" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
|
||||||
|
const tableSectionTemplate = document.createElement('template')
|
||||||
|
tableSectionTemplate.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
`
|
||||||
|
|
||||||
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = `
|
||||||
|
<table class="table table-sm table-zebra table-pin-rows"></table>
|
||||||
|
`
|
||||||
|
|
||||||
|
class CurrencyTable extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
const table = this.querySelector<HTMLTableElement>('table')!
|
||||||
|
|
||||||
|
CurrencyManager.getAllCurrencies().then((currencies) => {
|
||||||
|
const groups = this.groupAlphabetically(currencies)
|
||||||
|
|
||||||
|
Object.entries(groups).forEach(([letter, currencies]) => table.appendChild(this.buildSection(letter, currencies)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupAlphabetically = (currencies: CurrencyDBEntry[]): Record<string, CurrencyDBEntry[]> => {
|
||||||
|
const letterGroups: Record<string, CurrencyDBEntry[]> = {}
|
||||||
|
|
||||||
|
currencies.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||||
|
currencies.forEach((currency) => {
|
||||||
|
const firstLetter = currency.name[0].toUpperCase()
|
||||||
|
|
||||||
|
if (!letterGroups[firstLetter]) letterGroups[firstLetter] = []
|
||||||
|
|
||||||
|
letterGroups[firstLetter].push(currency)
|
||||||
|
})
|
||||||
|
|
||||||
|
return letterGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSection = (header: string, currencies: CurrencyDBEntry[]): Node => {
|
||||||
|
const section = tableSectionTemplate.content.cloneNode(true) as DocumentFragment
|
||||||
|
const th = section.querySelector<HTMLTableCellElement>('th')!
|
||||||
|
th.textContent = header
|
||||||
|
|
||||||
|
const body = section.querySelector<HTMLTableSectionElement>('tbody')!
|
||||||
|
currencies.forEach((currency) => body.appendChild(this.buildRow(currency)))
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRow = (currency: CurrencyDBEntry): Node => {
|
||||||
|
const row = tableRowTemplate.content.cloneNode(true) as DocumentFragment
|
||||||
|
|
||||||
|
const codeCell = row.querySelector<HTMLSpanElement>('#code')!
|
||||||
|
codeCell.textContent = currency.code
|
||||||
|
|
||||||
|
const nameCell = row.querySelector<HTMLTableCellElement>('#name')!
|
||||||
|
nameCell.textContent = currency.name
|
||||||
|
|
||||||
|
const toggle = row.querySelector<HTMLInputElement>('input[type="checkbox"]')!
|
||||||
|
toggle.name = currency.name
|
||||||
|
toggle.checked = currency.enabled
|
||||||
|
toggle.onchange = () => CurrencyManager.setCurrencyEnabled(currency.code, toggle.checked)
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('currency-table', CurrencyTable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrencyTable
|
||||||
104
src/lib/components/productCard.ts
Normal file
104
src/lib/components/productCard.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Currency } from '@lib/currency'
|
||||||
|
import ProductManager from '@lib/products'
|
||||||
|
|
||||||
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = `
|
||||||
|
<div class="card card-compact bg-base-100 relative">
|
||||||
|
<figure class="px-5 pt-5">
|
||||||
|
<a id="page-link">
|
||||||
|
<img id="image" class="h-full w-full rounded-lg object-cover" />
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
<div class="card-body items-center text-center !gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="title" class="line-clamp-1 card-title !text-lg"></h2>
|
||||||
|
<h3 id="subtitle" class="line-clamp-1 text-sm"></h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions justify-center">
|
||||||
|
<div id="stock-amount" class="tooltip tooltip-success">
|
||||||
|
<div id="stock-status" class="badge badge-outline"></div>
|
||||||
|
</div>
|
||||||
|
<div id="price" class="badge badge-neutral"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tooltip absolute -top-2.5 -right-2.5" data-tip="Delete">
|
||||||
|
<button id="delete" class="btn btn-circle btn-sm bg-base-100">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
class ProductCard extends HTMLElement {
|
||||||
|
public readonly product: Product
|
||||||
|
private readonly priceElement: HTMLDivElement
|
||||||
|
|
||||||
|
constructor(product: Product) {
|
||||||
|
super()
|
||||||
|
this.product = product
|
||||||
|
this.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
const pageLink = this.querySelector<HTMLAnchorElement>('#page-link')!
|
||||||
|
pageLink.href = `https://www.tanocstore.net/shopdetail/${product.id.padStart(12, '0')}`
|
||||||
|
pageLink.target = '_blank'
|
||||||
|
|
||||||
|
const image = this.querySelector<HTMLImageElement>('#image')!
|
||||||
|
image.src = this.product.imageUrl.toString()
|
||||||
|
image.alt = `${this.product.name} Jacket`
|
||||||
|
|
||||||
|
const title = this.querySelector<HTMLHeadingElement>('#title')!
|
||||||
|
title.textContent = this.product.name
|
||||||
|
|
||||||
|
const subtitle = this.querySelector<HTMLHeadingElement>('#subtitle')!
|
||||||
|
subtitle.textContent = `${this.product.seller} / ${this.product.modelNumber}`
|
||||||
|
|
||||||
|
if (product.inStock) {
|
||||||
|
const stockAmount = this.querySelector<HTMLDivElement>('#stock-amount')!
|
||||||
|
stockAmount.dataset.tip = `Stock: ${product.stockAmount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockStatus = this.querySelector<HTMLDivElement>('#stock-status')!
|
||||||
|
if (this.product.inStock) {
|
||||||
|
stockStatus.classList.remove('badge-error')
|
||||||
|
stockStatus.classList.add('badge-success')
|
||||||
|
stockStatus.textContent = 'In Stock'
|
||||||
|
} else {
|
||||||
|
stockStatus.classList.remove('badge-success')
|
||||||
|
stockStatus.classList.add('badge-error')
|
||||||
|
stockStatus.textContent = 'Out of Stock'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.priceElement = this.querySelector<HTMLDivElement>('#price')!
|
||||||
|
this.priceElement.textContent = `${this.product.price.amount} ${this.product.price.currency}`
|
||||||
|
|
||||||
|
const deleteButton = this.querySelector<HTMLButtonElement>('#delete')!
|
||||||
|
deleteButton.onclick = this.delete
|
||||||
|
}
|
||||||
|
|
||||||
|
public convertCurrency = (newCurrency: Currency, exchangeRate: number) => {
|
||||||
|
const newAmount = this.product.price.amount / exchangeRate
|
||||||
|
this.priceElement.textContent = `${newAmount.toFixed(newCurrency.decimal_digits)} ${newCurrency.code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete = () => {
|
||||||
|
ProductManager.delete(this.product.id)
|
||||||
|
this.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('product-card', ProductCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductCard
|
||||||
218
src/lib/components/productSearchModal.ts
Normal file
218
src/lib/components/productSearchModal.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import TanocStore from '@lib/tanocStore'
|
||||||
|
import ProductManager from '@lib/products'
|
||||||
|
import { AlertEvent } from './alertBox'
|
||||||
|
|
||||||
|
const spinner = `<span class="loading loading-spinner" />`
|
||||||
|
|
||||||
|
const searchSVG = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-4 w-4" fill="currentColor">
|
||||||
|
<!--!Font Awesome Free 6.7.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
|
||||||
|
<path
|
||||||
|
d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
|
||||||
|
const productPreviewTemplate = document.createElement('template')
|
||||||
|
productPreviewTemplate.innerHTML = `
|
||||||
|
<div class="card card-side card-compact bg-neutral shadow-xl h-24">
|
||||||
|
<figure class="flex-shrink-0">
|
||||||
|
<img id="preview-image" class="h-full aspect-square object-cover" />
|
||||||
|
</figure>
|
||||||
|
<div class="flex flex-col flex-auto p-4 justify-center overflow-hidden w-full">
|
||||||
|
<h2 id="product-name" class="card-title line-clamp-1"></h2>
|
||||||
|
<div class="text-neutral-content flex items-center gap-2">
|
||||||
|
<span id="seller" class="line-clamp-1"></span>
|
||||||
|
<div id="stock-indicator" class="flex-shrink-0 badge badge-outline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
class ProductPreview extends HTMLElement {
|
||||||
|
public readonly product: Product
|
||||||
|
|
||||||
|
constructor(product: Product) {
|
||||||
|
super()
|
||||||
|
this.product = product
|
||||||
|
this.appendChild(productPreviewTemplate.content.cloneNode(true))
|
||||||
|
|
||||||
|
const previewImage = this.querySelector<HTMLImageElement>('#preview-image')!
|
||||||
|
previewImage.src = product.imageUrl.toString()
|
||||||
|
previewImage.alt = `${product.name} display image`
|
||||||
|
|
||||||
|
const productName = this.querySelector<HTMLHeadingElement>('#product-name')!
|
||||||
|
productName.textContent = product.name
|
||||||
|
|
||||||
|
const seller = this.querySelector<HTMLSpanElement>('#seller')!
|
||||||
|
seller.textContent = product.seller
|
||||||
|
|
||||||
|
const stockIndicator = this.querySelector<HTMLDivElement>('#stock-indicator')!
|
||||||
|
stockIndicator.textContent = product.inStock ? 'In Stock' : 'Out of Stock'
|
||||||
|
stockIndicator.classList.add(product.inStock ? 'badge-success' : 'badge-error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = `
|
||||||
|
<dialog id="modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">Add Item</h3>
|
||||||
|
<form method="dialog" class="flex gap-4 my-4">
|
||||||
|
<input type="text" name="item-input" class="input input-bordered w-full" placeholder="Enter URL or product ID" />
|
||||||
|
<button id="search-button" type="button" disabled class="btn btn-square">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-4 w-4" fill="currentColor">
|
||||||
|
<!--!Font Awesome Free 6.7.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
|
||||||
|
<path
|
||||||
|
d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button id="add-item-button" disabled class="btn">Add Item</button>
|
||||||
|
<button id="close-button" class="btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
`
|
||||||
|
|
||||||
|
class ProductSearchModal extends HTMLElement {
|
||||||
|
private readonly modal: HTMLDialogElement
|
||||||
|
private readonly input: HTMLInputElement
|
||||||
|
private readonly form: HTMLFormElement
|
||||||
|
private readonly searchButton: HTMLButtonElement
|
||||||
|
private readonly addItemButton: HTMLButtonElement
|
||||||
|
private readonly closeButton: HTMLButtonElement
|
||||||
|
|
||||||
|
public onadd: ((addedProduct: Product) => any) | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
this.modal = this.querySelector<HTMLDialogElement>('dialog')!
|
||||||
|
|
||||||
|
this.form = this.querySelector<HTMLFormElement>('form')!
|
||||||
|
|
||||||
|
this.input = this.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||||
|
this.input.oninput = this.inputHandle
|
||||||
|
this.input.onchange = this.changeHandle
|
||||||
|
|
||||||
|
this.searchButton = this.querySelector<HTMLButtonElement>('#search-button')!
|
||||||
|
this.searchButton.onclick = this.searchHandle
|
||||||
|
|
||||||
|
this.addItemButton = this.querySelector<HTMLButtonElement>('#add-item-button')!
|
||||||
|
this.addItemButton.onclick = this.addItem
|
||||||
|
|
||||||
|
this.closeButton = this.querySelector<HTMLButtonElement>('#close-button')!
|
||||||
|
this.closeButton.onclick = this.close
|
||||||
|
}
|
||||||
|
|
||||||
|
public open = () => {
|
||||||
|
this.modal.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
public close = () => {
|
||||||
|
this.modal.close()
|
||||||
|
this.input.value = ''
|
||||||
|
this.inputState = 'empty'
|
||||||
|
setTimeout(() => (this.searchResult = null), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private set inputState(state: 'valid' | 'invalid' | 'empty') {
|
||||||
|
switch (state) {
|
||||||
|
case 'valid':
|
||||||
|
this.input.classList.remove('input-error')
|
||||||
|
this.input.classList.add('input-success')
|
||||||
|
this.searchButton.disabled = false
|
||||||
|
break
|
||||||
|
case 'invalid':
|
||||||
|
this.input.classList.remove('input-success')
|
||||||
|
this.input.classList.add('input-error')
|
||||||
|
this.searchButton.disabled = true
|
||||||
|
break
|
||||||
|
case 'empty':
|
||||||
|
this.input.classList.remove('input-success', 'input-error')
|
||||||
|
this.searchButton.disabled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get searchResult(): Product | null {
|
||||||
|
return this.querySelector<ProductPreview>('product-preview')?.product ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private set searchResult(product: Product | null) {
|
||||||
|
const currentResult = this.querySelector<ProductPreview>('product-preview')
|
||||||
|
|
||||||
|
if (product === null) {
|
||||||
|
currentResult?.remove()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPreview = new ProductPreview(product)
|
||||||
|
|
||||||
|
if (currentResult === null) {
|
||||||
|
this.form.insertAdjacentElement('afterend', newPreview)
|
||||||
|
} else {
|
||||||
|
currentResult.replaceWith(newPreview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inputHandle: GlobalEventHandlers['oninput'] = () => {
|
||||||
|
const value = this.input.value
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
|
this.inputState = 'empty'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validURL = URL.canParse(value) && /^https?:\/\/(?:www\.)?tanocstore\.net\/shopdetail\/\d{12}/.test(value)
|
||||||
|
const validID = /^\d{1,12}$/.test(value) && Number.parseInt(value) > 0
|
||||||
|
this.inputState = validURL || validID ? 'valid' : 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
private changeHandle: GlobalEventHandlers['onchange'] = () => {
|
||||||
|
const value = this.input.value
|
||||||
|
if (/^\d{1,11}$/.test(value)) this.input.value = value.padStart(12, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
private searchHandle: GlobalEventHandlers['onclick'] = async () => {
|
||||||
|
const productId = URL.canParse(this.input.value) ? /\d{12}/.exec(this.input.value)![0] : this.input.value
|
||||||
|
|
||||||
|
this.searchButton.disabled = true
|
||||||
|
this.searchButton.innerHTML = spinner
|
||||||
|
const product = await TanocStore.product(productId).catch(() => null)
|
||||||
|
this.searchButton.innerHTML = searchSVG
|
||||||
|
this.searchButton.disabled = false
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
this.dispatchEvent(new AlertEvent({ message: 'Invalid URL/ID', type: 'alert-error' }))
|
||||||
|
this.inputState = 'invalid'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchResult = product
|
||||||
|
this.addItemButton.disabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private addItem = () => {
|
||||||
|
if (this.searchResult === null) return
|
||||||
|
|
||||||
|
if (ProductManager.products.has(this.searchResult.id)) {
|
||||||
|
this.dispatchEvent(new AlertEvent({ message: 'Product already being tracked', type: 'alert-warning' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductManager.add(this.searchResult.id)
|
||||||
|
if (this.onadd) this.onadd(this.searchResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('product-preview', ProductPreview)
|
||||||
|
customElements.define('product-search-modal', ProductSearchModal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductSearchModal
|
||||||
42
src/lib/components/refreshExchangeRatesButton.ts
Normal file
42
src/lib/components/refreshExchangeRatesButton.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import CurrencyManager from '@lib/currency'
|
||||||
|
|
||||||
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = `
|
||||||
|
<button class="btn btn-primary">Refresh Exchange Rates</button>
|
||||||
|
`
|
||||||
|
|
||||||
|
class RefreshExchangeRatesButton extends HTMLElement {
|
||||||
|
private readonly button: HTMLButtonElement
|
||||||
|
public onrefresh: (() => any) | null = null
|
||||||
|
public onfail: (() => any) | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
this.button = this.querySelector('button')!
|
||||||
|
this.button.onclick = this.handleClick
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClick: GlobalEventHandlers['onclick'] = async () => {
|
||||||
|
this.button.disabled = true
|
||||||
|
this.button.innerHTML = `
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
Refreshing
|
||||||
|
`
|
||||||
|
try {
|
||||||
|
await CurrencyManager.refreshExchangeRates()
|
||||||
|
this.onrefresh?.call(null)
|
||||||
|
} catch {
|
||||||
|
this.onfail?.call(null)
|
||||||
|
}
|
||||||
|
this.button.innerText = `Refresh Exchange Rates`
|
||||||
|
this.button.disabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define(): void {
|
||||||
|
customElements.define('refresh-exchange-rates-button', RefreshExchangeRatesButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RefreshExchangeRatesButton
|
||||||
27
src/lib/components/tanocStoreLogo.ts
Normal file
27
src/lib/components/tanocStoreLogo.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import TanocStoreLogoImage from '@images/tanocStoreLogo.png'
|
||||||
|
|
||||||
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = `
|
||||||
|
<a class="bg-primary inline-block" href="https://www.tanocstore.net/" target="_blank">
|
||||||
|
<img alt="TANO*C STORE Logo" class="opacity-0" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
|
||||||
|
class TanocStoreLogo extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.appendChild(template.content.cloneNode(true))
|
||||||
|
|
||||||
|
const image = this.querySelector('img')!
|
||||||
|
image.src = TanocStoreLogoImage
|
||||||
|
|
||||||
|
const link = this.querySelector('a')!
|
||||||
|
link.style.maskImage = `url("${TanocStoreLogoImage}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
public static define() {
|
||||||
|
customElements.define('tanoc-store-logo', TanocStoreLogo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanocStoreLogo
|
||||||
179
src/lib/currency.ts
Normal file
179
src/lib/currency.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import ky from 'ky'
|
||||||
|
import { IDBPDatabase, openDB, StoreNames, type DBSchema } from 'idb'
|
||||||
|
|
||||||
|
export type Currency = {
|
||||||
|
symbol: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
decimal_digits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrencyAPI {
|
||||||
|
private static readonly api = ky.create({
|
||||||
|
prefixUrl: 'https://api.currencyapi.com/v3/',
|
||||||
|
method: 'get',
|
||||||
|
headers: { apikey: import.meta.env.VITE_CURRENCY_API_KEY },
|
||||||
|
})
|
||||||
|
|
||||||
|
public static async getCurrencies(): Promise<Record<string, Currency>> {
|
||||||
|
console.log('Fetched new currencies')
|
||||||
|
const response = await this.api('currencies').json<{ data: Record<string, Currency> }>()
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getExchangeRates(currencies?: Iterable<string>, baseCurrency: string = 'USD'): Promise<Record<string, number>> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
searchParams.set('base_currency', baseCurrency)
|
||||||
|
if (currencies) searchParams.set('currencies', Array.from(currencies).join(','))
|
||||||
|
|
||||||
|
console.log('Fetched new exchange rates')
|
||||||
|
const response = await this.api('latest', { searchParams }).json<{ data: Record<string, { code: string; value: number }> }>()
|
||||||
|
const exchangeRates: Record<string, number> = {}
|
||||||
|
Object.values(response.data).forEach(({ code, value }) => (exchangeRates[code] = value))
|
||||||
|
return exchangeRates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CurrencyDBEntry = Currency & { enabled: boolean }
|
||||||
|
|
||||||
|
interface CurrencyDBSchema extends DBSchema {
|
||||||
|
currencies: {
|
||||||
|
key: string // Currency Code
|
||||||
|
value: CurrencyDBEntry
|
||||||
|
}
|
||||||
|
// All exchange rates are in relation to USD (base_currency in CurrencyAPI)
|
||||||
|
exchangeRates: {
|
||||||
|
key: string // Currency Code
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CURRENCY = 'USD'
|
||||||
|
const DEFAULT_ENABLED_CURRENCIES = new Set<string>([DEFAULT_CURRENCY, 'EUR', 'GBP', 'JPY', 'CAD', 'AUD'])
|
||||||
|
|
||||||
|
class CurrencyDB {
|
||||||
|
private static dbInstance: IDBPDatabase<CurrencyDBSchema> | null = null
|
||||||
|
private static readonly storeConfig = new Map<StoreNames<CurrencyDBSchema>, IDBObjectStoreParameters | undefined>([
|
||||||
|
['currencies', { keyPath: 'code' }],
|
||||||
|
['exchangeRates', undefined],
|
||||||
|
])
|
||||||
|
|
||||||
|
private static async initDB(): Promise<IDBPDatabase<CurrencyDBSchema>> {
|
||||||
|
const db = await openDB<CurrencyDBSchema>('Currency', 1, {
|
||||||
|
upgrade: (db) =>
|
||||||
|
this.storeConfig.forEach((config, storeName) => {
|
||||||
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
|
db.createObjectStore(storeName, config)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const countTx = db.transaction(['currencies', 'exchangeRates'], 'readonly')
|
||||||
|
const currencyEntryCount = await countTx.objectStore('currencies').count()
|
||||||
|
const exchangeRatesEntryCount = await countTx.objectStore('exchangeRates').count()
|
||||||
|
await countTx.done
|
||||||
|
|
||||||
|
if (currencyEntryCount === 0) {
|
||||||
|
const currencies = await CurrencyAPI.getCurrencies()
|
||||||
|
const currecyDBEntries = Object.values(currencies).map(
|
||||||
|
(currency): CurrencyDBEntry => Object.assign(currency, { enabled: DEFAULT_ENABLED_CURRENCIES.has(currency.code) }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const currencyTx = db.transaction('currencies', 'readwrite')
|
||||||
|
await Promise.all(currecyDBEntries.map((currency) => currencyTx.store.put(currency)))
|
||||||
|
await currencyTx.done
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exchangeRatesEntryCount === 0) {
|
||||||
|
await this.initExchangeRates(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async initExchangeRates(db: IDBPDatabase<CurrencyDBSchema>) {
|
||||||
|
const newExchangeRates = await CurrencyAPI.getExchangeRates()
|
||||||
|
|
||||||
|
const tx = db.transaction('exchangeRates', 'readwrite')
|
||||||
|
await tx.store.clear()
|
||||||
|
await Promise.all(Object.entries(newExchangeRates).map(([key, val]) => tx.store.add(val, key)))
|
||||||
|
await tx.done
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async open(): Promise<IDBPDatabase<CurrencyDBSchema>> {
|
||||||
|
if (this.dbInstance === null) {
|
||||||
|
this.dbInstance = await this.initDB()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dbInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await CurrencyDB.open()
|
||||||
|
|
||||||
|
class CurrencyManager {
|
||||||
|
public static get activeCurrency(): string {
|
||||||
|
const storedCurrency = localStorage.getItem('currency')
|
||||||
|
if (storedCurrency === null) {
|
||||||
|
this.activeCurrency = DEFAULT_CURRENCY
|
||||||
|
return DEFAULT_CURRENCY
|
||||||
|
} else {
|
||||||
|
return storedCurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static set activeCurrency(currencyCode: string) {
|
||||||
|
localStorage.setItem('currency', currencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getCurrency(currencyCode: string): Promise<CurrencyDBEntry | undefined> {
|
||||||
|
return await db.get('currencies', currencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getAllCurrencies(): Promise<CurrencyDBEntry[]> {
|
||||||
|
return await db.getAll('currencies')
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getEnabledCurrencies(): Promise<CurrencyDBEntry[]> {
|
||||||
|
const enabledCurrencies: Array<Currency & { enabled: boolean }> = []
|
||||||
|
const tx = db.transaction('currencies')
|
||||||
|
for await (const cursor of tx.store) {
|
||||||
|
if (cursor.value.enabled) enabledCurrencies.push(cursor.value)
|
||||||
|
}
|
||||||
|
return enabledCurrencies
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async setCurrencyEnabled(currencyCode: string, enabled: boolean) {
|
||||||
|
const tx = db.transaction('currencies', 'readwrite')
|
||||||
|
const store = tx.store
|
||||||
|
const currency = await store.get(currencyCode)
|
||||||
|
if (!currency) throw Error(`Invalid currency code: ${currencyCode}`)
|
||||||
|
|
||||||
|
currency.enabled = enabled
|
||||||
|
await store.put(currency)
|
||||||
|
await tx.done
|
||||||
|
|
||||||
|
if (!enabled && this.activeCurrency === currency.code) {
|
||||||
|
this.activeCurrency = DEFAULT_CURRENCY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getExchangeRate(fromCurrencyCode: string, toCurrencyCode: string): Promise<number | undefined> {
|
||||||
|
const toUSD = await db.get('exchangeRates', fromCurrencyCode)
|
||||||
|
const toNewCurrency = await db.get('exchangeRates', toCurrencyCode)
|
||||||
|
|
||||||
|
if (!toUSD || !toNewCurrency) return undefined
|
||||||
|
|
||||||
|
return toUSD / toNewCurrency
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async setExchangeRate(currencyCode: string, exchangeRate: number): Promise<string> {
|
||||||
|
return await db.put('exchangeRates', exchangeRate, currencyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static refreshExchangeRates = async () => {
|
||||||
|
await CurrencyDB.initExchangeRates(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrencyManager
|
||||||
41
src/lib/products.ts
Normal file
41
src/lib/products.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
function isStringArray(json: any): json is string[] {
|
||||||
|
return Array.isArray(json) && json.every((item) => typeof item === 'string')
|
||||||
|
}
|
||||||
|
|
||||||
|
function canParseJSON(jsonString: string): boolean {
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonString)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductManager {
|
||||||
|
public static get products(): Set<string> {
|
||||||
|
const existingIdsString = localStorage.getItem('products')
|
||||||
|
|
||||||
|
if (existingIdsString && canParseJSON(existingIdsString)) {
|
||||||
|
const existingIds = JSON.parse(existingIdsString)
|
||||||
|
if (isStringArray(existingIds)) {
|
||||||
|
return new Set(existingIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static add(productId: string) {
|
||||||
|
const ids = this.products
|
||||||
|
ids.add(productId)
|
||||||
|
localStorage.setItem('products', JSON.stringify(Array.from(ids)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static delete(productId: string) {
|
||||||
|
const ids = this.products
|
||||||
|
ids.delete(productId)
|
||||||
|
localStorage.setItem('products', JSON.stringify(Array.from(ids)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProductManager
|
||||||
72
src/lib/tanocStore.ts
Normal file
72
src/lib/tanocStore.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import ky from 'ky'
|
||||||
|
|
||||||
|
const JST_OFFSET = 9
|
||||||
|
|
||||||
|
const api = ky.create({
|
||||||
|
prefixUrl: 'https://makeshop.worldshopping.jp/prod/',
|
||||||
|
method: 'get',
|
||||||
|
searchParams: { shopKey: 'tanocstore_net' },
|
||||||
|
})
|
||||||
|
|
||||||
|
class TanocStore {
|
||||||
|
public static async product(productId: string): Promise<Product> {
|
||||||
|
productId = productId.padStart(12, '0')
|
||||||
|
|
||||||
|
const productData = await api('product', { searchParams: { productId } }).json<WSAPI.Product>()
|
||||||
|
|
||||||
|
const { product_name, price, vendor, ubrand_code, stock, image_url, created_date } = productData
|
||||||
|
const stockAmount = Number.parseInt(stock)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: productId,
|
||||||
|
name: product_name,
|
||||||
|
seller: vendor,
|
||||||
|
modelNumber: ubrand_code,
|
||||||
|
price: {
|
||||||
|
amount: Number(price),
|
||||||
|
currency: 'JPY',
|
||||||
|
},
|
||||||
|
inStock: stockAmount > 0,
|
||||||
|
stockAmount,
|
||||||
|
imageUrl: new URL(image_url),
|
||||||
|
releaseDate: parseDateString(created_date, JST_OFFSET),
|
||||||
|
distributer: 'TANO*C Store',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateString(dateString: string, timezoneOffset: number = 0): Date {
|
||||||
|
const year = Number.parseInt(dateString.substring(0, 4), 10)
|
||||||
|
const month = Number.parseInt(dateString.substring(4, 6), 10) - 1
|
||||||
|
const day = Number.parseInt(dateString.substring(6, 8), 10)
|
||||||
|
const hour = Number.parseInt(dateString.substring(8, 10), 10)
|
||||||
|
const minute = Number.parseInt(dateString.substring(10, 12), 10)
|
||||||
|
const second = Number.parseInt(dateString.substring(12, 14), 10)
|
||||||
|
|
||||||
|
return new Date(Date.UTC(year, month, day, hour - timezoneOffset, minute, second))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TanocStore
|
||||||
|
|
||||||
|
declare namespace WSAPI {
|
||||||
|
type Product = {
|
||||||
|
created_date: string //yyyymmddhhmmss
|
||||||
|
ubrand_code: string // Product code
|
||||||
|
categories: Array<{
|
||||||
|
category_code: string
|
||||||
|
category_path: string
|
||||||
|
}>
|
||||||
|
product_name: string
|
||||||
|
price: string
|
||||||
|
vendor: string // Label
|
||||||
|
stock: string
|
||||||
|
image_url: string
|
||||||
|
main_content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtraProductDetails = {
|
||||||
|
stockAmount: number
|
||||||
|
categories: string[]
|
||||||
|
videoUrl?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/lib/themes.ts
Normal file
36
src/lib/themes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
type Theme = 'dark' | 'light' | 'synthwave' | 'valentine' | 'aqua' | 'dracula'
|
||||||
|
|
||||||
|
class ThemeManager {
|
||||||
|
public static readonly DEFAULT_THEME: Theme = 'dark'
|
||||||
|
public static readonly VALID_THEMES = new Set<Theme>(['dark', 'light', 'synthwave', 'valentine', 'aqua', 'dracula'])
|
||||||
|
|
||||||
|
public static get activeTheme(): Theme {
|
||||||
|
const storedTheme = localStorage.getItem('theme')
|
||||||
|
if (!storedTheme || !this.validTheme(storedTheme)) {
|
||||||
|
this.activeTheme = this.DEFAULT_THEME
|
||||||
|
return this.DEFAULT_THEME
|
||||||
|
}
|
||||||
|
return storedTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
public static set activeTheme(theme: Theme) {
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static validTheme(theme: string): theme is Theme {
|
||||||
|
return this.VALID_THEMES.has(theme as Theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTheme() {
|
||||||
|
const html = document.querySelector('html')
|
||||||
|
if (!html) {
|
||||||
|
console.error('Failed to load theme')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dataset.theme = ThemeManager.activeTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeManager
|
||||||
|
export { loadTheme, type Theme }
|
||||||
54
src/routes/app.css
Normal file
54
src/routes/app.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@100..900&family=Noto+Sans+JP:wght@100..900&family=Noto+Sans+KR:wght@100..900&family=Noto+Sans+SC:wght@100..900&family=Noto+Sans+TC:wght@100..900&family=Noto+Sans:wght@100..900&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yes, there is a reason I am not using repeat(auto-fill) here */
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
@media (min-width: 400px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1050px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1350px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1700px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 2300px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
#card-wrapper {
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/routes/dashboard/index.html
Normal file
40
src/routes/dashboard/index.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nicholas Tamassia - CS343 Project - Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="../app.css" />
|
||||||
|
<script type="module">
|
||||||
|
import { loadTheme } from '@lib/themes'
|
||||||
|
loadTheme()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="no-scrollbar relative h-screen overflow-x-clip overflow-y-scroll bg-base-300 font-notoSans">
|
||||||
|
<nav class="navbar sticky top-0 isolate z-50 bg-base-100 px-4 font-sans">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<tanoc-store-logo class="hidden place-items-center md:grid"></tanoc-store-logo>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-center">
|
||||||
|
<h1 class="hidden md:inline-block">
|
||||||
|
<strong class="px-3 text-2xl">Tracked Items</strong>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<a class="btn btn-ghost" href="../settings/">Settings</a>
|
||||||
|
<currency-selector class="w-20"></currency-selector>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="flex items-center gap-2 p-2">
|
||||||
|
<button id="add-product-button" class="btn bg-base-100">Add Item</button>
|
||||||
|
<button id="export-products-button" class="btn bg-base-100">Export</button>
|
||||||
|
</div>
|
||||||
|
<main id="card-wrapper" class="my-8 grid w-full gap-5 px-[5%]"></main>
|
||||||
|
<product-search-modal></product-search-modal>
|
||||||
|
<alert-box class="fixed z-50"></alert-box>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
src/routes/dashboard/main.ts
Normal file
70
src/routes/dashboard/main.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import TanocStore from '@lib/tanocStore'
|
||||||
|
import CurrencyManager from '@lib/currency'
|
||||||
|
import ProductManager from '@lib/products'
|
||||||
|
|
||||||
|
/* Web Components */
|
||||||
|
import ProductCard from '@lib/components/productCard'
|
||||||
|
import TanocStoreLogo from '@lib/components/tanocStoreLogo'
|
||||||
|
import CurrencySelector from '@lib/components/currencySelector'
|
||||||
|
import ProductSearchModal from '@lib/components/productSearchModal'
|
||||||
|
import AlertBox, { AlertEvent } from '@lib/components/alertBox'
|
||||||
|
|
||||||
|
AlertBox.define()
|
||||||
|
ProductCard.define()
|
||||||
|
TanocStoreLogo.define()
|
||||||
|
CurrencySelector.define()
|
||||||
|
ProductSearchModal.define()
|
||||||
|
/* Web Components */
|
||||||
|
|
||||||
|
const productMap = new Map<string, ProductCard>()
|
||||||
|
|
||||||
|
const cardWrapper = document.querySelector<HTMLElement>('#card-wrapper')!
|
||||||
|
|
||||||
|
const alerBox = document.querySelector<AlertBox>('alert-box')!
|
||||||
|
|
||||||
|
const currencySelector = document.querySelector<CurrencySelector>('currency-selector')!
|
||||||
|
currencySelector.oncurrencychange = (event) => convertProductPrices(event.detail.newCurrencyCode, Array.from(productMap.values()))
|
||||||
|
|
||||||
|
const productSearchModal = document.querySelector<ProductSearchModal>('product-search-modal')!
|
||||||
|
productSearchModal.addEventListener('alert', ((event: AlertEvent) => alerBox.add(event.detail)) as EventListener)
|
||||||
|
productSearchModal.onadd = constructCard
|
||||||
|
|
||||||
|
const addProductButton = document.querySelector<HTMLButtonElement>('#add-product-button')!
|
||||||
|
addProductButton.onclick = productSearchModal.open
|
||||||
|
|
||||||
|
const exportProductsButton = document.querySelector<HTMLButtonElement>('#export-products-button')!
|
||||||
|
exportProductsButton.onclick = () => exportProducts(Array.from(productMap.values(), (card) => card.product))
|
||||||
|
|
||||||
|
function constructCard(product: Product): ProductCard {
|
||||||
|
const card = new ProductCard(product)
|
||||||
|
productMap.set(product.id, card)
|
||||||
|
cardWrapper.appendChild(card)
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertProductPrices(currencyCode: string, products: ProductCard[]) {
|
||||||
|
const newCurrency = await CurrencyManager.getCurrency(currencyCode)
|
||||||
|
async function exchangeProductCurrency(productCard: ProductCard) {
|
||||||
|
const exchangeRate = await CurrencyManager.getExchangeRate(productCard.product.price.currency, currencyCode)
|
||||||
|
|
||||||
|
if (!newCurrency || !exchangeRate) throw Error('Failed to find exchange rate')
|
||||||
|
|
||||||
|
return productCard.convertCurrency(newCurrency, exchangeRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(products.map(exchangeProductCurrency))
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProducts(products: Product[]) {
|
||||||
|
const blob = new Blob([JSON.stringify({ products })], { type: 'text/plain' })
|
||||||
|
const objectURL = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = objectURL
|
||||||
|
link.download = 'products.json'
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(objectURL)
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Initialization */
|
||||||
|
ProductManager.products.forEach((id) => TanocStore.product(id).then(constructCard))
|
||||||
26
src/routes/index.html
Normal file
26
src/routes/index.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nicholas Tamassia - CS343 Project - Home</title>
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
<script type="module">
|
||||||
|
import { loadTheme } from '@lib/themes'
|
||||||
|
loadTheme()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="hero min-h-screen" style="background-image: url(@images/albumcollage.png)">
|
||||||
|
<div class="hero-overlay !bg-[#15191e] !bg-opacity-85"></div>
|
||||||
|
<div class="hero-content text-center text-neutral-content">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<h1 class="mb-5 text-5xl font-bold">TANO*C Store Tracker</h1>
|
||||||
|
<p class="mb-5">A simple dashboard to keep track of the stock of items from TANO*C Store</p>
|
||||||
|
<a class="btn btn-primary" href="./dashboard/">Go to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
src/routes/settings/index.html
Normal file
72
src/routes/settings/index.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nicholas Tamassia - CS343 Project - Settings</title>
|
||||||
|
<link rel="stylesheet" href="../app.css" />
|
||||||
|
<script type="module">
|
||||||
|
import { loadTheme } from '@lib/themes'
|
||||||
|
loadTheme()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="no-scrollbar relative h-screen overflow-x-clip overflow-y-scroll bg-base-300 font-notoSans">
|
||||||
|
<nav class="navbar sticky top-0 isolate z-10 bg-base-100 px-4 font-sans">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<tanoc-store-logo class="hidden place-items-center md:grid"></tanoc-store-logo>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-center">
|
||||||
|
<h1 class="hidden md:inline-block">
|
||||||
|
<strong class="px-3 text-2xl">Settings</strong>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<a class="btn btn-ghost" href="../dashboard/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="my-8 w-full px-[5%]">
|
||||||
|
<div class="mx-auto flex w-full max-w-screen-lg flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:gap-0">
|
||||||
|
<div class="card rounded-box bg-base-100 lg:flex-[1.5]">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Currencies</h2>
|
||||||
|
<p>Enable and Disable the currencies you would like to be able to convert to</p>
|
||||||
|
<div id="currency-actions" class="card-actions justify-end">
|
||||||
|
<refresh-exchange-rates-button></refresh-exchange-rates-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:divider lg:divider-horizontal"></div>
|
||||||
|
<currency-table class="card h-80 overflow-x-auto rounded-box bg-base-100 lg:flex-1"></currency-table>
|
||||||
|
</div>
|
||||||
|
<div class="divider !my-0 lg:hidden"></div>
|
||||||
|
<div class="flex flex-col gap-2 lg:flex-row lg:gap-0">
|
||||||
|
<div class="card rounded-box bg-base-100 lg:flex-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Customization</h2>
|
||||||
|
<p>Customize the interface to your heart's desire!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lg:divider lg:divider-horizontal"></div>
|
||||||
|
<div class="card h-80 rounded-box bg-base-100 lg:flex-[1.5]">
|
||||||
|
<div class="card-body">
|
||||||
|
<fieldset class="join flex-wrap">
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Dark" value="dark" />
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Light" value="light" />
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Synthwave" value="synthwave" />
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Valentine" value="valentine" />
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Aqua" value="aqua" />
|
||||||
|
<input type="radio" name="theme-buttons" class="theme-controller btn join-item" aria-label="Dracula" value="dracula" />
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<alert-box class="fixed z-50"></alert-box>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="settings.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
src/routes/settings/settings.ts
Normal file
32
src/routes/settings/settings.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import ThemeManager from '@lib/themes'
|
||||||
|
|
||||||
|
/* Web Components */
|
||||||
|
import AlertBox from '@lib/components/alertBox'
|
||||||
|
import CurrencyTable from '@lib/components/currencyTable'
|
||||||
|
import TanocStoreLogo from '@lib/components/tanocStoreLogo'
|
||||||
|
import RefreshExchangeRatesButton from '@lib/components/refreshExchangeRatesButton'
|
||||||
|
|
||||||
|
AlertBox.define()
|
||||||
|
CurrencyTable.define()
|
||||||
|
TanocStoreLogo.define()
|
||||||
|
RefreshExchangeRatesButton.define()
|
||||||
|
/* Web Components */
|
||||||
|
|
||||||
|
const alertBox = document.querySelector<AlertBox>('alert-box')!
|
||||||
|
|
||||||
|
const refreshExchangeRatesButton = document.querySelector<RefreshExchangeRatesButton>('refresh-exchange-rates-button')!
|
||||||
|
refreshExchangeRatesButton.onrefresh = () => alertBox.add({ message: 'Refreshed exchange rates', type: 'alert-success' })
|
||||||
|
refreshExchangeRatesButton.onfail = () => alertBox.add({ message: 'Failed to refresh exchange rates', type: 'alert-error' })
|
||||||
|
|
||||||
|
const themeButtons = document.querySelectorAll<HTMLInputElement>('input[name="theme-buttons"]')!
|
||||||
|
themeButtons.forEach((button) => {
|
||||||
|
if (button.value === ThemeManager.activeTheme) {
|
||||||
|
button.checked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
button.onchange = () => {
|
||||||
|
if (button.checked && ThemeManager.validTheme(button.value)) {
|
||||||
|
ThemeManager.activeTheme = button.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_CURRENCY_API_KEY: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
18
tailwind.config.js
Normal file
18
tailwind.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import daisyui from 'daisyui'
|
||||||
|
import * as defaultTheme from 'tailwindcss/defaultTheme'
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.{html,js,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
notoSans: ["'Noto Sans', 'Noto Sans HK', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC'", ...defaultTheme.fontFamily.sans],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [daisyui],
|
||||||
|
daisyui: {
|
||||||
|
themes: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@lib/*": ["src/lib/*"],
|
||||||
|
"@images/*": ["src/images/*"]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src", "src/app.d.ts"]
|
||||||
|
}
|
||||||
32
vite.config.ts
Normal file
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const root = 'src/routes' // Entry point
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './', // Ensure all paths are relative
|
||||||
|
root,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@lib': resolve(__dirname, 'src/lib'),
|
||||||
|
'@images': resolve(__dirname, 'src/images'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
supported: {
|
||||||
|
'top-level-await': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, root, 'index.html'),
|
||||||
|
dashboard: resolve(__dirname, root, 'dashboard/index.html'),
|
||||||
|
settings: resolve(__dirname, root, 'settings/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minify: false, // So professor can actually read the files
|
||||||
|
outDir: '../dist', // Build output to dist directory in src
|
||||||
|
},
|
||||||
|
envDir: '../../',
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user