init
This commit is contained in:
10
app/internal/view/front/src/components/auth/api.ts
Normal file
10
app/internal/view/front/src/components/auth/api.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Client, type APIResponse } from "../../share/utils/client";
|
||||
import type { User } from "./entity";
|
||||
|
||||
export function SignUp(user: User): Promise<APIResponse> {
|
||||
return Client.Post("auth/signup", user);
|
||||
}
|
||||
|
||||
export function Login(email: string, password: string): Promise<APIResponse> {
|
||||
return Client.Post("auth/login", { email, password });
|
||||
}
|
||||
21
app/internal/view/front/src/components/auth/entity.ts
Normal file
21
app/internal/view/front/src/components/auth/entity.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { Entity } from "../../share/type/entity";
|
||||
|
||||
export interface User extends Entity {
|
||||
name: string
|
||||
email: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface UserSignUp {
|
||||
name: string
|
||||
email: string
|
||||
password?: string
|
||||
rePassword: string;
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
|
||||
2
app/internal/view/front/src/components/auth/index.css
Normal file
2
app/internal/view/front/src/components/auth/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './signup/index.css';
|
||||
@import './login/index.css';
|
||||
2
app/internal/view/front/src/components/auth/index.ts
Normal file
2
app/internal/view/front/src/components/auth/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import './signup'
|
||||
import './login'
|
||||
28
app/internal/view/front/src/components/auth/login/index.css
Normal file
28
app/internal/view/front/src/components/auth/login/index.css
Normal file
@ -0,0 +1,28 @@
|
||||
#c_auth_login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#c_auth_login__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#c_auth_login__error {
|
||||
color: red;
|
||||
margin: 1rem auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.c_auth_login__links {
|
||||
margin: 1rem auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
11
app/internal/view/front/src/components/auth/login/index.tmpl
Normal file
11
app/internal/view/front/src/components/auth/login/index.tmpl
Normal file
@ -0,0 +1,11 @@
|
||||
<div id="c_auth_login">
|
||||
<form id="c_auth_login__form">
|
||||
{% include "share/ui/input/text/index.tmpl" with label="Email" name="email" error="Eamil is erforderlich" %}
|
||||
{% include "share/ui/input/password/index.tmpl" with label="Password" name="password" error="Password ist erforderlich" %}
|
||||
{% include "share/ui/btn/primary/index.tmpl" with label="Anmelden" type="submit" %}
|
||||
</form>
|
||||
<div id="c_auth_login__error"></div>
|
||||
<div class="c_auth_login__links">
|
||||
<a href="/auth/signup">Noch keinen Account? Jetzt registrieren</a>
|
||||
</div>
|
||||
</div>
|
||||
58
app/internal/view/front/src/components/auth/login/index.ts
Normal file
58
app/internal/view/front/src/components/auth/login/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { LoadingSpin } from "../../../share/ui/btn";
|
||||
import { ErrorMessage } from "../../../share/ui/error";
|
||||
import { NavigateAfterLogin } from "../../../share/utils/navigate";
|
||||
import { Login } from "../api";
|
||||
import { type UserLogin } from "../entity";
|
||||
|
||||
function init() {
|
||||
const logIn = document.querySelector("#c_auth_login") as HTMLElement;
|
||||
if (!logIn) return;
|
||||
const form: HTMLFormElement | null = logIn.querySelector("#c_auth_login__form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
const errInfo = logIn.querySelector("#c_auth_login__error") as HTMLElement;
|
||||
if (!errInfo) return;
|
||||
|
||||
form.addEventListener("submit", (e) => submit(e, form, errInfo), false);
|
||||
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent, form: HTMLFormElement, errInfo: Element) {
|
||||
e.preventDefault();
|
||||
|
||||
errInfo.textContent = "";
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const userLogin: UserLogin = {
|
||||
email: data.email as string,
|
||||
password: data.password as string,
|
||||
}
|
||||
|
||||
if (!validate(form, userLogin)) {
|
||||
return
|
||||
}
|
||||
LoadingSpin(e.submitter as HTMLButtonElement, true);
|
||||
const res = await Login(userLogin.email, userLogin.password);
|
||||
LoadingSpin(e.submitter as HTMLButtonElement, false);
|
||||
if (res.status === 200) {
|
||||
NavigateAfterLogin();
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
errInfo.textContent = res.message || "Login failed";
|
||||
}
|
||||
}
|
||||
|
||||
function validate(form: HTMLFormElement, user: UserLogin): boolean {
|
||||
let isValid = true
|
||||
if (!user.email || !/^\S+@\S+\.\S+$/.test(user.email)) {
|
||||
ErrorMessage(form, "email");
|
||||
isValid = false
|
||||
}
|
||||
if (!user.password || user.password.length < 8 || user.password.length > 100) {
|
||||
ErrorMessage(form, "password");
|
||||
isValid = false
|
||||
}
|
||||
return isValid
|
||||
}
|
||||
|
||||
|
||||
init();
|
||||
21
app/internal/view/front/src/components/auth/signup/index.css
Normal file
21
app/internal/view/front/src/components/auth/signup/index.css
Normal file
@ -0,0 +1,21 @@
|
||||
#c_auth_signup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#c_auth_signup__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#c_auth_signup__error {
|
||||
color: red;
|
||||
margin: 1rem auto;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<div id="c_auth_signup">
|
||||
<form id="c_auth_signup__form">
|
||||
{% include "share/ui/input/text/index.tmpl" with label="Name" name="name" error="Name muss zwischen 3 und 50 Zeichen lang sein" %}
|
||||
{% include "share/ui/input/text/index.tmpl" with label="Email" name="email" error="Bitte eine gültige E-Mail-Adresse eingeben" %}
|
||||
{% include "share/ui/input/password/index.tmpl" with label="Password" name="password" error="Passwort muss zwischen 8 und 100 Zeichen lang sein" %}
|
||||
{% include "share/ui/input/password/index.tmpl" with label="Passwort wiederholen" name="rePassword" error="Bitte Passwort wiederholen" %}
|
||||
{% include "share/ui/btn/primary/index.tmpl" with label="Sign Up" type="submit" %}
|
||||
</form>
|
||||
<div id="c_auth_signup__error"></div>
|
||||
</div>
|
||||
75
app/internal/view/front/src/components/auth/signup/index.ts
Normal file
75
app/internal/view/front/src/components/auth/signup/index.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { LoadingSpin } from "../../../share/ui/btn";
|
||||
import { ErrorMessage } from "../../../share/ui/error";
|
||||
import { NavigateAfterLogin } from "../../../share/utils/navigate";
|
||||
import { SignUp } from "../api";
|
||||
import { type User, type UserSignUp } from "../entity";
|
||||
|
||||
function init() {
|
||||
const signUp = document.querySelector("#c_auth_signup") as HTMLElement;
|
||||
if (!signUp) return;
|
||||
const form: HTMLFormElement | null = signUp.querySelector("#c_auth_signup__form") as HTMLFormElement;
|
||||
if (!form) return;
|
||||
const errInfo = signUp.querySelector("#c_auth_signup__error") as HTMLElement;
|
||||
if (!errInfo) return;
|
||||
|
||||
form.addEventListener("submit", (e) => submit(e, form, errInfo), false);
|
||||
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent, form: HTMLFormElement, errInfo: HTMLElement) {
|
||||
e.preventDefault();
|
||||
|
||||
errInfo.textContent = "";
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const userSignUp: UserSignUp = {
|
||||
email: data.email as string,
|
||||
name: data.name as string,
|
||||
password: data.password as string,
|
||||
rePassword: data.rePassword as string,
|
||||
}
|
||||
|
||||
if (!validate(form, userSignUp)) {
|
||||
return
|
||||
}
|
||||
const user: User = {
|
||||
email: userSignUp.email,
|
||||
name: userSignUp.name,
|
||||
password: userSignUp.password,
|
||||
}
|
||||
LoadingSpin(e.submitter as HTMLButtonElement, true);
|
||||
const res = await SignUp(user);
|
||||
LoadingSpin(e.submitter as HTMLButtonElement, false);
|
||||
if (res.status === 201) {
|
||||
NavigateAfterLogin();
|
||||
return
|
||||
}
|
||||
if (res.status !== 201) {
|
||||
errInfo.textContent = res.message || "Sign up failed";
|
||||
}
|
||||
}
|
||||
|
||||
function validate(form: HTMLFormElement, user: UserSignUp): boolean {
|
||||
let isValid = true
|
||||
if (!user.name || user.name.length < 3 || user.name.length > 50) {
|
||||
ErrorMessage(form, "name");
|
||||
isValid = false
|
||||
}
|
||||
if (!user.email || !/^\S+@\S+\.\S+$/.test(user.email)) {
|
||||
ErrorMessage(form, "email");
|
||||
isValid = false
|
||||
}
|
||||
if (!user.password || user.password.length < 8 || user.password.length > 100) {
|
||||
ErrorMessage(form, "password");
|
||||
isValid = false
|
||||
}
|
||||
if (!user.rePassword || user.password != user.rePassword) {
|
||||
ErrorMessage(form, "rePassword");
|
||||
isValid = false
|
||||
}
|
||||
return isValid
|
||||
}
|
||||
|
||||
|
||||
init();
|
||||
2
app/internal/view/front/src/components/index.css
Normal file
2
app/internal/view/front/src/components/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './qr/index.css';
|
||||
@import './auth/index.css';
|
||||
3
app/internal/view/front/src/components/index.ts
Normal file
3
app/internal/view/front/src/components/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import "./qr";
|
||||
import "./auth";
|
||||
|
||||
14
app/internal/view/front/src/components/qr/api.ts
Normal file
14
app/internal/view/front/src/components/qr/api.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Client, type APIResponse } from "../../share/utils/client";
|
||||
import type { QR, QRGenerate } from "./entity";
|
||||
|
||||
export function Generate(qr: QRGenerate): Promise<APIResponse> {
|
||||
return Client.Post("qr/generate", qr);
|
||||
}
|
||||
|
||||
export function Create(qr: QR): Promise<APIResponse> {
|
||||
return Client.Post("qr", qr);
|
||||
}
|
||||
|
||||
export function Deelete(id: string): Promise<APIResponse> {
|
||||
return Client.Delete("qr/" + id);
|
||||
}
|
||||
14
app/internal/view/front/src/components/qr/entity.ts
Normal file
14
app/internal/view/front/src/components/qr/entity.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Entity } from "../../share/type/entity";
|
||||
|
||||
export interface QRGenerate {
|
||||
url: string;
|
||||
img_format: string;
|
||||
img_size?: number;
|
||||
}
|
||||
|
||||
export interface QR extends Entity {
|
||||
slug?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
#c_qr_e_add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#c_qr_e_add__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
<div id="c_qr_e_add">
|
||||
<form id="c_qr_e_add__form">
|
||||
{% include "share/ui/input/text/index.tmpl" with label="URL" name="url" value="https://" error="URL ist erforderlich" %}
|
||||
{% include "share/ui/btn/primary/index.tmpl" with label="Create Follow QR" type="submit" %}
|
||||
</form>
|
||||
</div>
|
||||
25
app/internal/view/front/src/components/qr/event/add/index.ts
Normal file
25
app/internal/view/front/src/components/qr/event/add/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Create } from "../../api";
|
||||
|
||||
function add() {
|
||||
const form = document.querySelector('#c_qr_e_add__form') as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', (e) => submit(e, form));
|
||||
}
|
||||
add();
|
||||
|
||||
async function submit(e: Event, form: HTMLFormElement) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const qr = {
|
||||
url: data.url as string,
|
||||
}
|
||||
const res = await Create(qr)
|
||||
if (res.statusText === "error") {
|
||||
alert(res.error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
.c_qr_e_generate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.c_qr_e_generate button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #0474cd;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
|
||||
background: #0050ae;
|
||||
color: #ffffff;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c_qr_e_generate button:hover {
|
||||
background: #0474cd;
|
||||
border-color: #0050ae;
|
||||
}
|
||||
|
||||
.c_qr_e_generate button:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.c_qr_e_generate button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.c_qr_e_generate .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<form class="c_qr_e_generate {{ class }}">
|
||||
{% if qr %}
|
||||
<input type="hidden" name="url" value="{{ HOST }}/redirect/{{ qr.Slug() }}">
|
||||
{% else %}
|
||||
<div class="field">
|
||||
<label for="url">Enter URL:</label>
|
||||
<input type="text" name="url" placeholder="Enter URL" value="https://">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label for"format">Format:</label>
|
||||
<select name="format">
|
||||
<option value="png">PNG</option>
|
||||
<option value="svg">SVG</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="size">Size (px):</label>
|
||||
<input type="number" name="size" placeholder="Size (e.g., 250)" value="250">
|
||||
</div>
|
||||
<button type="submit">Generate & Download</button>
|
||||
</form>
|
||||
@ -0,0 +1,47 @@
|
||||
import { downloadFile } from "../../../../share/utils/client/response/file";
|
||||
import { Generate } from "../../api";
|
||||
|
||||
function generate() {
|
||||
const forms = document.querySelectorAll('.c_qr_e_generate') as NodeListOf<HTMLFormElement>;
|
||||
if (!forms) return;
|
||||
|
||||
for (const form of forms) {
|
||||
form.addEventListener('submit', (e) => submit(e, form));
|
||||
|
||||
const selectFormat = form.querySelector('select[name="format"]') as HTMLSelectElement;
|
||||
const size = form.querySelector('input[name="size"]') as HTMLInputElement;
|
||||
if (selectFormat && size) {
|
||||
selectFormat.addEventListener('change', () => changeFormat(size, selectFormat));
|
||||
}
|
||||
}
|
||||
}
|
||||
generate();
|
||||
|
||||
async function submit(e: Event, form: HTMLFormElement) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const qr = {
|
||||
url: data.url as string,
|
||||
img_format: data.format as string,
|
||||
img_size: data.size ? parseInt(data.size as string) : undefined,
|
||||
}
|
||||
const res = await Generate(qr)
|
||||
if (res.statusText === "error") {
|
||||
alert(res.error);
|
||||
}
|
||||
if (res.status === 200) {
|
||||
downloadFile("qr", res.data)
|
||||
}
|
||||
}
|
||||
|
||||
function changeFormat(size: HTMLInputElement, selectFormat: HTMLSelectElement) {
|
||||
const value = selectFormat.value;
|
||||
if (value === "png") {
|
||||
size.disabled = false;
|
||||
} else {
|
||||
size.disabled = true;
|
||||
size.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
@import './generate/index.css';
|
||||
@import './add/index.css';
|
||||
2
app/internal/view/front/src/components/qr/event/index.ts
Normal file
2
app/internal/view/front/src/components/qr/event/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import './add'
|
||||
import './generate'
|
||||
2
app/internal/view/front/src/components/qr/index.css
Normal file
2
app/internal/view/front/src/components/qr/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './event/index.css';
|
||||
@import './ui/index.css';
|
||||
3
app/internal/view/front/src/components/qr/index.ts
Normal file
3
app/internal/view/front/src/components/qr/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import './event';
|
||||
import './ui';
|
||||
|
||||
2
app/internal/view/front/src/components/qr/ui/index.css
Normal file
2
app/internal/view/front/src/components/qr/ui/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './list/index.css';
|
||||
@import './item/index.css';
|
||||
1
app/internal/view/front/src/components/qr/ui/index.ts
Normal file
1
app/internal/view/front/src/components/qr/ui/index.ts
Normal file
@ -0,0 +1 @@
|
||||
import './item'
|
||||
54
app/internal/view/front/src/components/qr/ui/item/index.css
Normal file
54
app/internal/view/front/src/components/qr/ui/item/index.css
Normal file
@ -0,0 +1,54 @@
|
||||
.c_qr_ui_item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.c_qr_ui_item .info {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.c_qr_ui_item .link {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c_qr_ui_item .stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.c_qr_ui_item .actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.c_qr_ui_item .download {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.c_qr_ui_item .download img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.c_qr_ui_item .generate_qr_form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
26
app/internal/view/front/src/components/qr/ui/item/index.tmpl
Normal file
26
app/internal/view/front/src/components/qr/ui/item/index.tmpl
Normal file
@ -0,0 +1,26 @@
|
||||
<div class="c_qr_ui_item" data-id="{{ qr.ID() }}">
|
||||
<div class="actions">
|
||||
<button class="download" data-action="generate">
|
||||
<img src="{{ S3_BUCKET }}/public/images/icons/qr.svg"
|
||||
width="30"
|
||||
height="auto"
|
||||
alt="Generate QR" />
|
||||
<span>Generate QR</span>
|
||||
</button>
|
||||
<button class="remove" data-action="remove">
|
||||
<img src="{{ S3_BUCKET }}/public/images/icons/remove.svg"
|
||||
width="20"
|
||||
height="auto"
|
||||
alt="remove" />
|
||||
</button>
|
||||
</div>
|
||||
{% include "components/qr/event/generate/index.tmpl" with qr=qr class="hidden" %}
|
||||
<div class="info">
|
||||
<div class="link">
|
||||
<a href="{{ qr.URL() }}" target="_blank">{{ qr.URL() }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>Visits: {{ qr.Visits() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
42
app/internal/view/front/src/components/qr/ui/item/index.ts
Normal file
42
app/internal/view/front/src/components/qr/ui/item/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Deelete } from "../../api";
|
||||
|
||||
function deleteItemEvent() {
|
||||
const items = document.querySelectorAll('.c_qr_ui_item') as NodeListOf<HTMLElement>;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
const btnDelete = item.querySelector('[data-action="remove"]') as HTMLButtonElement;
|
||||
const btnGenerate = item.querySelector('[data-action="generate"]') as HTMLButtonElement;
|
||||
if (btnDelete) {
|
||||
btnDelete.addEventListener('click', () => deleteItem(item));
|
||||
}
|
||||
if (btnGenerate) {
|
||||
btnGenerate.addEventListener('click', () => openGenerateForm(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleteItemEvent();
|
||||
|
||||
async function deleteItem(item: Element) {
|
||||
|
||||
const id = item.getAttribute('data-id');
|
||||
if (!id) return;
|
||||
|
||||
if (!confirm("Are you sure you want to delete this QR code?")) return;
|
||||
|
||||
const res = await Deelete(id);
|
||||
|
||||
if (res.status === 404) {
|
||||
alert(res.error);
|
||||
}
|
||||
|
||||
if (res.status === 200) {
|
||||
item.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function openGenerateForm(item: Element) {
|
||||
const form = item.querySelector('.c_qr_e_generate') as HTMLFormElement;
|
||||
if (!form) return;
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
#c_qr_ui_list #list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 800px;
|
||||
margin: 50px auto 0 auto;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
<div id="c_qr_ui_list">
|
||||
<h2>QR Codes</h2>
|
||||
<div>{% include "components/qr/event/add/index.tmpl" %}</div>
|
||||
<div id="list">
|
||||
{% for qr in qrs %}
|
||||
{% include "components/qr/ui/item/index.tmpl" with qr=qr %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
14
app/internal/view/front/src/layout/base.tmpl
Normal file
14
app/internal/view/front/src/layout/base.tmpl
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
{% include "partials/head/index.tmpl" %}
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include "partials/header/index.tmpl" %}
|
||||
<main id="base">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% include "partials/footer/index.tmpl" %}
|
||||
</body>
|
||||
</html>
|
||||
6
app/internal/view/front/src/layout/index.css
Normal file
6
app/internal/view/front/src/layout/index.css
Normal file
@ -0,0 +1,6 @@
|
||||
#base {
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
7
app/internal/view/front/src/pages/auth/login/index.tmpl
Normal file
7
app/internal/view/front/src/pages/auth/login/index.tmpl
Normal file
@ -0,0 +1,7 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_auth_signup">
|
||||
<h1>Anmelden</h1>
|
||||
{% include "components/auth/login/index.tmpl" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
7
app/internal/view/front/src/pages/auth/signup/index.tmpl
Normal file
7
app/internal/view/front/src/pages/auth/signup/index.tmpl
Normal file
@ -0,0 +1,7 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_auth_signup">
|
||||
<h1>Sign Up</h1>
|
||||
{% include "components/auth/signup/index.tmpl" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
app/internal/view/front/src/pages/error/404/index.css
Normal file
13
app/internal/view/front/src/pages/error/404/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
#p_error_404 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
#p_error_404 .message {
|
||||
margin: 2rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
12
app/internal/view/front/src/pages/error/404/index.tmpl
Normal file
12
app/internal/view/front/src/pages/error/404/index.tmpl
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_error_404">
|
||||
<img src="{{ S3_BUCKET }}/public/images/icons/404.svg"
|
||||
alt="404 Not Found"
|
||||
width="300px"
|
||||
height="auto" />
|
||||
<div class="message">
|
||||
<a href="/">Go to Home page</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
14
app/internal/view/front/src/pages/error/500/index.css
Normal file
14
app/internal/view/front/src/pages/error/500/index.css
Normal file
@ -0,0 +1,14 @@
|
||||
#p_error_500 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#p_error_500 a {
|
||||
margin: 20px 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
8
app/internal/view/front/src/pages/error/500/index.tmpl
Normal file
8
app/internal/view/front/src/pages/error/500/index.tmpl
Normal file
@ -0,0 +1,8 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_error_500">
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>Sorry, something went wrong on our end. Please try again later.</p>
|
||||
<a href="/">Go back to Home</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
11
app/internal/view/front/src/pages/error/500dev/index.css
Normal file
11
app/internal/view/front/src/pages/error/500dev/index.css
Normal file
@ -0,0 +1,11 @@
|
||||
#p_error_500dev {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
color: red;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}<div id="p_error_500dev">{{ error }}</div>{% endblock %}
|
||||
3
app/internal/view/front/src/pages/error/index.css
Normal file
3
app/internal/view/front/src/pages/error/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@import './404/index.css';
|
||||
@import './500/index.css';
|
||||
@import './500dev/index.css';
|
||||
12
app/internal/view/front/src/pages/impressum/index.tmpl
Normal file
12
app/internal/view/front/src/pages/impressum/index.tmpl
Normal file
@ -0,0 +1,12 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_impressum">
|
||||
<h1>Impressum</h1>
|
||||
<div>Michael Held</div>
|
||||
<div>Ortsstr. 4</div>
|
||||
<div>93161 Sinzing</div>
|
||||
<div>Deutschland</div>
|
||||
<div>Tel.: 017664640917</div>
|
||||
<div>E-Mail: michael@heldm.de</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
1
app/internal/view/front/src/pages/index.css
Normal file
1
app/internal/view/front/src/pages/index.css
Normal file
@ -0,0 +1 @@
|
||||
@import './error/index.css';
|
||||
7
app/internal/view/front/src/pages/index.tmpl
Normal file
7
app/internal/view/front/src/pages/index.tmpl
Normal file
@ -0,0 +1,7 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
<div id="p_home">
|
||||
<h1>Generate QR</h1>
|
||||
{% include "components/qr/event/generate/index.tmpl" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
4
app/internal/view/front/src/pages/qr/index.tmpl
Normal file
4
app/internal/view/front/src/pages/qr/index.tmpl
Normal file
@ -0,0 +1,4 @@
|
||||
{% extends "layout/base.tmpl" %}
|
||||
{% block content %}
|
||||
{% include "components/qr/ui/list/index.tmpl" %}
|
||||
{% endblock %}
|
||||
16
app/internal/view/front/src/partials/footer/index.css
Normal file
16
app/internal/view/front/src/partials/footer/index.css
Normal file
@ -0,0 +1,16 @@
|
||||
footer {
|
||||
background-color: rgb(64 64 64);
|
||||
color: white;
|
||||
width: 100%;
|
||||
font-weight: 300;
|
||||
padding: min(1rem, 5%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
6
app/internal/view/front/src/partials/footer/index.tmpl
Normal file
6
app/internal/view/front/src/partials/footer/index.tmpl
Normal file
@ -0,0 +1,6 @@
|
||||
<footer>
|
||||
<div>
|
||||
<a href="/impressum">Impressum</a>
|
||||
</div>
|
||||
<div>v{{ appVersion }}</div>
|
||||
</footer>
|
||||
11
app/internal/view/front/src/partials/footer/index.ts
Normal file
11
app/internal/view/front/src/partials/footer/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
function FooterButton() {
|
||||
const btn = document.getElementById('footer-btn') as HTMLButtonElement
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
alert('Footer button clicked!')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
FooterButton();
|
||||
8
app/internal/view/front/src/partials/head/index.tmpl
Normal file
8
app/internal/view/front/src/partials/head/index.tmpl
Normal file
@ -0,0 +1,8 @@
|
||||
<title>{{ title }}</title>
|
||||
<meta name="description" content="{{ description }}" />
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/public/index.css?v={{ appVersion }}">
|
||||
<script type="module" defer src="/public/index.js?v={{ appVersion }}"></script>
|
||||
<meta name="robots"
|
||||
content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1">
|
||||
15
app/internal/view/front/src/partials/header/index.css
Normal file
15
app/internal/view/front/src/partials/header/index.css
Normal file
@ -0,0 +1,15 @@
|
||||
header {
|
||||
padding: 20px;
|
||||
background-color: #f8f8f8;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
header nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
22
app/internal/view/front/src/partials/header/index.tmpl
Normal file
22
app/internal/view/front/src/partials/header/index.tmpl
Normal file
@ -0,0 +1,22 @@
|
||||
<header>
|
||||
<nav>
|
||||
<div>
|
||||
<a href="/">Home</a>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div>
|
||||
<a href="/qr">My QR Codes</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/auth/logout">Log Out</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<a href="/auth/signup">Sign Up</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/auth/login">Login</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
2
app/internal/view/front/src/partials/index.css
Normal file
2
app/internal/view/front/src/partials/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './header/index.css';
|
||||
@import './footer/index.css';
|
||||
1
app/internal/view/front/src/partials/index.ts
Normal file
1
app/internal/view/front/src/partials/index.ts
Normal file
@ -0,0 +1 @@
|
||||
import './footer';
|
||||
1
app/internal/view/front/src/share/index.css
Normal file
1
app/internal/view/front/src/share/index.css
Normal file
@ -0,0 +1 @@
|
||||
@import './ui/index.css';
|
||||
10
app/internal/view/front/src/share/type/entity.ts
Normal file
10
app/internal/view/front/src/share/type/entity.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface Entity {
|
||||
ID?: string;
|
||||
CreatedAt?: Date;
|
||||
UpdatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface FileEntity extends Entity {
|
||||
ALT?: string;
|
||||
SRC?: string;
|
||||
}
|
||||
46
app/internal/view/front/src/share/ui/btn/index.css
Normal file
46
app/internal/view/front/src/share/ui/btn/index.css
Normal file
@ -0,0 +1,46 @@
|
||||
@import './primary/index.css';
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* spinner styles */
|
||||
.btn.loading {
|
||||
position: relative;
|
||||
--spinner-size: 32px;
|
||||
}
|
||||
|
||||
.btn.loading::after {
|
||||
content: "";
|
||||
width: var(--spinner-size);
|
||||
height: var(--spinner-size);
|
||||
border: 2px solid red;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Disable button styles */
|
||||
.btn.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
4
app/internal/view/front/src/share/ui/btn/index.tmpl
Normal file
4
app/internal/view/front/src/share/ui/btn/index.tmpl
Normal file
@ -0,0 +1,4 @@
|
||||
<button class="btn {{ class }}"
|
||||
{% if entity %}data-entity-type="{{ entity }}"{% endif %}
|
||||
{% if id %}data-entity-id="{{ id }}"{% endif %}
|
||||
{% if type %}type="{{ type }}"{% endif %}>{{ label }}</button>
|
||||
9
app/internal/view/front/src/share/ui/btn/index.ts
Normal file
9
app/internal/view/front/src/share/ui/btn/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function LoadingSpin(btn: Element, isLoading: boolean, size: string = "32px"): void {
|
||||
if (!isLoading) {
|
||||
btn.classList.remove("loading", "disabled");
|
||||
(btn as HTMLElement).style.removeProperty('--spinner-size');
|
||||
return;
|
||||
}
|
||||
btn.classList.add("loading", "disabled");
|
||||
(btn as HTMLElement).style.setProperty('--spinner-size', size);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
.primary {
|
||||
background: #0050ae;
|
||||
color: #ffffff;
|
||||
border: 1px solid #0474cd;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{% include "share/ui/btn/index.tmpl" with class="primary " + class %}
|
||||
8
app/internal/view/front/src/share/ui/error/index.css
Normal file
8
app/internal/view/front/src/share/ui/error/index.css
Normal file
@ -0,0 +1,8 @@
|
||||
.su_error {
|
||||
color: red;
|
||||
margin: 0.2rem 0.16rem;
|
||||
}
|
||||
|
||||
.su_error_disable {
|
||||
display: none;
|
||||
}
|
||||
2
app/internal/view/front/src/share/ui/error/index.tmpl
Normal file
2
app/internal/view/front/src/share/ui/error/index.tmpl
Normal file
@ -0,0 +1,2 @@
|
||||
<div id="error_{{ name }}" class="su_error su_error_disable">{{ error }}</div>
|
||||
<div class="error-message"></div>
|
||||
37
app/internal/view/front/src/share/ui/error/index.ts
Normal file
37
app/internal/view/front/src/share/ui/error/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
let focus = false;
|
||||
export function ErrorMessage(form: HTMLFormElement | null, name: string): void {
|
||||
if (form === null) return;
|
||||
|
||||
const element = document.querySelector<HTMLInputElement | HTMLSelectElement>(
|
||||
"input[name=" + name + "], select[name=" + name + "], textarea[name=" + name + "]",
|
||||
);
|
||||
if (element === null) return;
|
||||
|
||||
const errorElement = document.getElementById("error_" + name);
|
||||
if (errorElement === null) return;
|
||||
|
||||
if (!focus) {
|
||||
focus = true;
|
||||
element.focus();
|
||||
// make focus false after 1 second
|
||||
setTimeout(() => {
|
||||
focus = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
errorElement.classList.remove("su_error_disable");
|
||||
clearErrorMessage(element, errorElement);
|
||||
}
|
||||
|
||||
// Clear error on change input
|
||||
function clearErrorMessage(
|
||||
input: HTMLInputElement | HTMLSelectElement,
|
||||
errorElement: Element | null,
|
||||
): void {
|
||||
if (errorElement === null) return;
|
||||
input.addEventListener("input", () => {
|
||||
errorElement.classList.add("su_error_disable");
|
||||
input.removeEventListener("input", () => { });
|
||||
focus = false;
|
||||
});
|
||||
}
|
||||
2
app/internal/view/front/src/share/ui/index.css
Normal file
2
app/internal/view/front/src/share/ui/index.css
Normal file
@ -0,0 +1,2 @@
|
||||
@import './btn/index.css';
|
||||
@import './error/index.css';
|
||||
27
app/internal/view/front/src/share/ui/input/index.css
Normal file
27
app/internal/view/front/src/share/ui/input/index.css
Normal file
@ -0,0 +1,27 @@
|
||||
.su_input {
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.su_input label {
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.su_input input {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid gray;
|
||||
border-radius: 5px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.su_input input.error {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
/* .error-message { */
|
||||
/* color: red; */
|
||||
/* font-size: 0.875em; */
|
||||
/* margin-top: 4px; */
|
||||
/* } */
|
||||
23
app/internal/view/front/src/share/ui/input/index.tmpl
Normal file
23
app/internal/view/front/src/share/ui/input/index.tmpl
Normal file
@ -0,0 +1,23 @@
|
||||
<div class="su_input {{ class }}" id="div{{ name }}">
|
||||
<label for='{{ name }}'
|
||||
{% if classLabel %}class="{{ classLabel }}"{% endif %}>
|
||||
{{ label }}
|
||||
</label>
|
||||
<input type="{{ type }}"
|
||||
name="{{ name }}"
|
||||
id="{{ name }}"
|
||||
{% if value %}value="{{ value }}"{% endif %}
|
||||
{% if autofocus %}autofocus{% endif %}
|
||||
{% if checked %}checked{% endif %}
|
||||
{% if required %}required{% endif %}
|
||||
{% if maxlength %}maxlength="{{ maxlength }}"{% endif %}
|
||||
{% if minlength %}minlength="{{ minlength }}"{% endif %}
|
||||
{% if max %}max="{{ max }}"{% endif %}
|
||||
{% if min %}min="{{ min }}"{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
{% if pattern %}pattern="{{ pattern }}"{% endif %}
|
||||
{% if disabled %}disabled{% endif %}
|
||||
{% if readonly %}readonly{% endif %}
|
||||
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %} />
|
||||
{% include "share/ui/error/index.tmpl" with name=name error=error %}
|
||||
</div>
|
||||
@ -0,0 +1 @@
|
||||
{% include "share/ui/input/index.tmpl" with type="password" %}
|
||||
@ -0,0 +1 @@
|
||||
{% include "share/ui/input/index.tmpl" with type="text" %}
|
||||
83
app/internal/view/front/src/share/utils/client/index.ts
Normal file
83
app/internal/view/front/src/share/utils/client/index.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { sendRequest } from "./request";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type RequestOptions = {
|
||||
method?: HttpMethod;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
auth?: boolean;
|
||||
credentials?: RequestCredentials;
|
||||
};
|
||||
|
||||
export type Meta = {
|
||||
Total: number;
|
||||
Limit: number;
|
||||
Page: number;
|
||||
Count: number;
|
||||
};
|
||||
|
||||
export interface APIResponse {
|
||||
data?: any | null;
|
||||
meta?: Meta | null;
|
||||
message?: string;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
error?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RequestError {
|
||||
message: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export const Get = async (endpoint: string): Promise<APIResponse> => {
|
||||
const options: RequestOptions = {
|
||||
method: "GET",
|
||||
};
|
||||
return await sendRequest(endpoint, options);
|
||||
}
|
||||
|
||||
export const Post = async (url: string, body: any): Promise<APIResponse> => {
|
||||
const options: RequestOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
return await sendRequest(url, options);
|
||||
};
|
||||
|
||||
export const Patch = async (url: string, body: any): Promise<APIResponse> => {
|
||||
const options: RequestOptions = {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
|
||||
return await sendRequest(url, options);
|
||||
};
|
||||
|
||||
export const Delete = async (url: string): Promise<APIResponse> => {
|
||||
const options: RequestOptions = {
|
||||
method: "DELETE",
|
||||
};
|
||||
|
||||
return await sendRequest(url, options);
|
||||
};
|
||||
|
||||
|
||||
export const Client = {
|
||||
Get: Get,
|
||||
Post: Post,
|
||||
Patch: Patch,
|
||||
Delete: Delete,
|
||||
}
|
||||
85
app/internal/view/front/src/share/utils/client/request.ts
Normal file
85
app/internal/view/front/src/share/utils/client/request.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { RequestOptions } from ".";
|
||||
import { getAPIResponse } from "./response";
|
||||
|
||||
const API_URL: string = (() => {
|
||||
const url = import.meta.env.VITE_API_URL;
|
||||
|
||||
if (!url) {
|
||||
throw new Error("VITE_API_URL is not defined");
|
||||
}
|
||||
|
||||
return `${url.replace(/\/$/, "")}/api/`;
|
||||
})();
|
||||
|
||||
function getJwt(): string | null {
|
||||
try {
|
||||
return localStorage.getItem("jwt");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildBody(body: unknown): BodyInit | undefined {
|
||||
if (body == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (typeof body === "string") {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
function buildHeaders(
|
||||
extraHeaders: Record<string, string> = {},
|
||||
auth = true,
|
||||
): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
|
||||
if (auth) {
|
||||
const token = getJwt();
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function sendRequest(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
) {
|
||||
const {
|
||||
method = "GET",
|
||||
body,
|
||||
headers = {},
|
||||
auth = true,
|
||||
credentials = "include",
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const res = await fetch(API_URL + url, {
|
||||
method,
|
||||
headers: buildHeaders(headers, auth),
|
||||
credentials,
|
||||
body: buildBody(body),
|
||||
});
|
||||
|
||||
return await getAPIResponse(res)
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
status: 0,
|
||||
statusText: "NETWORK_ERROR",
|
||||
error: error instanceof Error ? error.stack : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import type { APIResponse } from "..";
|
||||
|
||||
export async function respondFile(res: Response): Promise<APIResponse> {
|
||||
const blob = await res.blob();
|
||||
return {
|
||||
data: blob,
|
||||
message: "",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: res.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadFile(fileName: string, blob: Blob) {
|
||||
const a = window.document.createElement("a");
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
a.href = objectUrl;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
}, 1000);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import type { APIResponse } from "..";
|
||||
|
||||
export async function respondHTML(res: Response): Promise<APIResponse> {
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
message: "Server error",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: res.url,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const respAPI: APIResponse = {
|
||||
data: text,
|
||||
message: "",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: res.url,
|
||||
};
|
||||
return respAPI;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import type { APIResponse } from "..";
|
||||
import { respondFile } from "./file";
|
||||
import { respondHTML } from "./html";
|
||||
import { respondJSON } from "./json";
|
||||
|
||||
export async function getAPIResponse(res: Response): Promise<APIResponse> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
|
||||
// return json
|
||||
if (contentType.includes("application/json")) {
|
||||
return await respondJSON(res);
|
||||
}
|
||||
|
||||
// return file
|
||||
if (
|
||||
contentType.includes("application/octet-stream") ||
|
||||
contentType.includes("image/") ||
|
||||
contentType.includes("application/pdf") ||
|
||||
contentType.includes("audio/") ||
|
||||
contentType.includes("video/")
|
||||
) {
|
||||
return await respondFile(res);
|
||||
}
|
||||
|
||||
// return html or text
|
||||
return await respondHTML(res);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import type { APIResponse } from "../index";
|
||||
|
||||
export async function respondJSON(res: Response): Promise<APIResponse> {
|
||||
|
||||
if (!res.ok) {
|
||||
let resAPI: APIResponse = {}
|
||||
// App error
|
||||
const data = await res.json();
|
||||
if (!data) {
|
||||
resAPI = {
|
||||
message: "Empty response body",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: res.url,
|
||||
};
|
||||
return resAPI;
|
||||
}
|
||||
|
||||
if (res.status === 401 && !res.url.includes("/auth/login")) {
|
||||
window.location.href = "/auth/login";
|
||||
}
|
||||
|
||||
resAPI = {
|
||||
message: data.message || res.statusText,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: data.data || null,
|
||||
url: res.url,
|
||||
};
|
||||
return resAPI;
|
||||
}
|
||||
|
||||
|
||||
const data = await res.json();
|
||||
const respAPI: APIResponse = {
|
||||
data: data.data,
|
||||
meta: data.meta,
|
||||
message: data.message || "",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
url: res.url,
|
||||
};
|
||||
return respAPI;
|
||||
}
|
||||
25
app/internal/view/front/src/share/utils/navigate.ts
Normal file
25
app/internal/view/front/src/share/utils/navigate.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export function NavigateBack() {
|
||||
const currentHost = window.location.hostname;
|
||||
|
||||
const referrer = document.referrer; // Previous page URL
|
||||
let referrerHost = "";
|
||||
|
||||
if (referrer) {
|
||||
const referrerURL = new URL(referrer);
|
||||
referrerHost = referrerURL.hostname;
|
||||
}
|
||||
|
||||
if (currentHost === referrerHost) {
|
||||
if (referrer.startsWith('/auth/')) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
window.location.href = referrer; // Redirect to the referrer page
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
||||
export function NavigateAfterLogin() {
|
||||
window.location.href = "/qr";
|
||||
}
|
||||
97
app/internal/view/front/src/styles/globals.css
Normal file
97
app/internal/view/front/src/styles/globals.css
Normal file
@ -0,0 +1,97 @@
|
||||
:root {
|
||||
--bg-color: #fbfaf5;
|
||||
--font-color: #2e3237;
|
||||
--border-color: #e1e1e1;
|
||||
--input-bg-color: #ffffff;
|
||||
--error-color: #ff0000;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--font-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: var(--input-bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--font-color);
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
select {
|
||||
background-color: var(--input-bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--font-color);
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
line-height: 1.2;
|
||||
/* disable Firefox default arrow */
|
||||
-moz-appearance: none;
|
||||
/* disable modern browsers’ generic styling */
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 2) Custom arrow via SVG data-URI */
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0l5 6 5-6' fill='%23666'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75em center;
|
||||
background-size: 0.65em auto;
|
||||
}
|
||||
|
||||
/* 3) Optional: focus outline */
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #66afe9;
|
||||
box-shadow: 0 0 0 3px rgba(102, 175, 233, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 96vh;
|
||||
}
|
||||
79
app/internal/view/front/src/styles/reset.css
Normal file
79
app/internal/view/front/src/styles/reset.css
Normal file
@ -0,0 +1,79 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
touch-action: manipulation;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-wrap: balance;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#root,
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
0
app/internal/view/front/src/styles/typography.css
Normal file
0
app/internal/view/front/src/styles/typography.css
Normal file
Reference in New Issue
Block a user