- started implementing frontend to backend
This commit is contained in:
parent
3c9f7323ba
commit
6abc36bc9b
@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
|
|||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request)
|
public async Task<Results<Ok, RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null)
|
||||||
{
|
{
|
||||||
var user = await FindUserAsync(request.UserNameOrEmail);
|
var user = await FindUserAsync(request.UserNameOrEmail);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
@ -46,6 +46,9 @@ namespace OnlyPrompt.Backend.Controllers
|
|||||||
|
|
||||||
var token = _jwtService.BuildToken(user, out var validUntil);
|
var token = _jwtService.BuildToken(user, out var validUntil);
|
||||||
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
|
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
|
||||||
|
if (string.IsNullOrEmpty(redirect) == false)
|
||||||
|
return TypedResults.Redirect(redirect, false);
|
||||||
|
|
||||||
return TypedResults.Ok();
|
return TypedResults.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
|
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
|
||||||
|
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
|
||||||
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
|
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/OnlyPrompt.Backend"
|
WORKDIR "/src/OnlyPrompt.Backend"
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Migrations\" />
|
<Folder Include="Migrations\" />
|
||||||
|
<Folder Include="wwwroot\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Rewrite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Identity.Web;
|
using Microsoft.Identity.Web;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@ -11,6 +14,7 @@ using OnlyPrompt.Backend.Services.Jwt;
|
|||||||
using OnlyPrompt.Backend.Utils;
|
using OnlyPrompt.Backend.Utils;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var config = builder.Configuration;
|
var config = builder.Configuration;
|
||||||
@ -25,10 +29,14 @@ builder.Services.AddDbContext<OnlyPromptContext>(opts =>
|
|||||||
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
|
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
|
||||||
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
||||||
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
|
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
|
||||||
|
builder.Services.AddValidation(opts =>
|
||||||
|
{
|
||||||
|
opts.MaxDepth = 10;
|
||||||
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => {
|
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
|
||||||
|
{
|
||||||
opts.TokenValidationParameters = new TokenValidationParameters
|
opts.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
@ -51,7 +59,10 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers().AddJsonOptions(jsonOpts =>
|
||||||
|
{
|
||||||
|
jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
});
|
||||||
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
|
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@ -65,10 +76,28 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
var rewrite = new RewriteOptions()
|
||||||
|
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
|
||||||
|
|
||||||
|
app.UseRewriter(rewrite);
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(dir),
|
||||||
|
RedirectToAppendTrailingSlash = true,
|
||||||
|
HttpsCompression = HttpsCompressionMode.Compress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
app.UseStaticFiles();
|
||||||
|
}
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapFallbackToFile("/login.html");
|
||||||
using var scope = app.Services.CreateScope();
|
using var scope = app.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
|
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
|
|||||||
16
OnlyPrompt.Frontend/.vscode/launch.json
vendored
Normal file
16
OnlyPrompt.Frontend/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Open current page in Edge",
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
|
"url": "https://localhost:7163/${fileBasename}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
OnlyPrompt.Frontend/.vscode/settings.json
vendored
Normal file
4
OnlyPrompt.Frontend/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.indentSize": 2
|
||||||
|
}
|
||||||
@ -17,3 +17,25 @@ body {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form errors */
|
||||||
|
.form-error {
|
||||||
|
color: red;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
list-style: '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error li .error {
|
||||||
|
color: red;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
140
OnlyPrompt.Frontend/js/linq.js
Normal file
140
OnlyPrompt.Frontend/js/linq.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
// LINQ-like Enumerable class wrapping lazy generator chains
|
||||||
|
|
||||||
|
class Enumerable {
|
||||||
|
constructor(iteratorFn) {
|
||||||
|
this._iteratorFn = iteratorFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator]() {
|
||||||
|
return this._iteratorFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
_chain(generatorFn) {
|
||||||
|
const source = this;
|
||||||
|
return new Enumerable(function* () {
|
||||||
|
yield* generatorFn(source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
where(predicate) {
|
||||||
|
return this._chain(function* (source) {
|
||||||
|
for (const item of source)
|
||||||
|
if (predicate(item))
|
||||||
|
yield item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
select(selector) {
|
||||||
|
return this._chain(function* (source) {
|
||||||
|
for (const item of source)
|
||||||
|
yield selector(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
take(count) {
|
||||||
|
count = Math.max(0, count);
|
||||||
|
return this._chain(function* (source) {
|
||||||
|
for (const item of source) {
|
||||||
|
if (count-- <= 0) break;
|
||||||
|
yield item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(count) {
|
||||||
|
count = Math.max(0, count);
|
||||||
|
return this._chain(function* (source) {
|
||||||
|
for (const item of source) {
|
||||||
|
if (count-- > 0) continue;
|
||||||
|
yield item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isLast() {
|
||||||
|
return this._chain(function* (source) {
|
||||||
|
const iter = source[Symbol.iterator]();
|
||||||
|
let current = iter.next();
|
||||||
|
let index = 0;
|
||||||
|
while (!current.done) {
|
||||||
|
const next = iter.next();
|
||||||
|
yield [current.value, next.done, index];
|
||||||
|
current = next;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(action) {
|
||||||
|
for (const item of this) {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
action(...item);
|
||||||
|
} else {
|
||||||
|
action(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() {
|
||||||
|
return Array.from(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
firstOrDefault(predicate) {
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
for (const item of source) return item;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
first(predicate) {
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
for (const item of source) return item;
|
||||||
|
throw new Error("No elements in sequence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lastOrDefault(predicate) {
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
let lastValue = undefined;
|
||||||
|
for (const item of source) lastValue = item;
|
||||||
|
return lastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
last(predicate) {
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
let lastValue = undefined;
|
||||||
|
let found = false;
|
||||||
|
for (const item of source) {
|
||||||
|
lastValue = item;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
if (!found) throw new Error("No elements in sequence.");
|
||||||
|
return lastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
any(predicate) {
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
for (const _ of source) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
all(predicate) {
|
||||||
|
for (const item of this)
|
||||||
|
if (!predicate(item))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
count(predicate) {
|
||||||
|
let count = 0;
|
||||||
|
const source = predicate ? this.where(predicate) : this;
|
||||||
|
for (const _ of source) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.asEnumerable = function () {
|
||||||
|
const arr = this;
|
||||||
|
return new Enumerable(function* () {
|
||||||
|
for (const item of arr)
|
||||||
|
yield item;
|
||||||
|
});
|
||||||
|
}
|
||||||
21
OnlyPrompt.Frontend/js/login.js
Normal file
21
OnlyPrompt.Frontend/js/login.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { sendFormAsync } from "./shared.js";
|
||||||
|
|
||||||
|
function togglePassword() {
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
|
||||||
|
passwordInput.type = newInputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLoginForm(){
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
await sendFormAsync(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const togglePasswordButton = document.getElementById('togglePassword');
|
||||||
|
togglePasswordButton.addEventListener('click', togglePassword);
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
loginForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault(); // Prevent the default form submission
|
||||||
|
await submitLoginForm();
|
||||||
|
});
|
||||||
148
OnlyPrompt.Frontend/js/shared.js
Normal file
148
OnlyPrompt.Frontend/js/shared.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import './linq.js'
|
||||||
|
import { Template } from './template.js';
|
||||||
|
|
||||||
|
export function formToObject(form) {
|
||||||
|
const data = new FormData(form);
|
||||||
|
const object = {};
|
||||||
|
data.forEach((value, key) => {
|
||||||
|
setNestedValue(object, key, value);
|
||||||
|
});
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedValue(obj, path, value) {
|
||||||
|
path.split('.').asEnumerable()
|
||||||
|
.isLast()
|
||||||
|
.forEach((key, isLast) => {
|
||||||
|
if (isLast) {
|
||||||
|
obj[key] = value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!obj[key]) {
|
||||||
|
obj[key] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
obj = obj[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendFormAsync(form, url, method) {
|
||||||
|
url = url || form.action;
|
||||||
|
method = method || form.method || 'post';
|
||||||
|
const data = formToObject(form);
|
||||||
|
const response = await sendJsonAsync(url, data, method);
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (response.ok == false && handleValidationError(response, responseText, form)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok == false) {
|
||||||
|
handleGenericFormError(response, responseText, form);
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
if(response.redirected){
|
||||||
|
window.location.href = response.url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText.length == 0 ? null : JSON.parse(responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendJsonAsync(url, data, method = 'post') {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAndRenderAsync(url, data, template, targetElement) {
|
||||||
|
const response = await sendJsonAsync(url, data);
|
||||||
|
if (response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postFormAndRenderAsync(url, form, template, targetElement) {
|
||||||
|
const object = formToObject(form);
|
||||||
|
const data = await postFormAsync(url, object, template, targetElement);
|
||||||
|
if (data) {
|
||||||
|
targetElement.innerHTML = template.render(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const genericFormErrorTemplate = new Template(`
|
||||||
|
<div class="form-error">
|
||||||
|
An error occurred while submitting the form. Please try again later.
|
||||||
|
{{ $this }}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
function handleGenericFormError(response, responseText, form) {
|
||||||
|
if (!response.ok) {
|
||||||
|
const html = genericFormErrorTemplate.render(responseText);
|
||||||
|
form.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrorTemplate = new Template(`
|
||||||
|
<div class="form-error">
|
||||||
|
<ul>
|
||||||
|
@for(error of $this) {
|
||||||
|
<li class="error">{{error}}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const unknownInputErrorTemplate = new Template(`
|
||||||
|
<div class="form-error">
|
||||||
|
<p>An error occurred with the following fields:</p>
|
||||||
|
@for(field, errors of Object.entries($this)) {
|
||||||
|
<ul>
|
||||||
|
@for(error of errors) {
|
||||||
|
<li class="error">{{field}}: {{error}}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
function handleValidationError(response, responseText, form) {
|
||||||
|
if (response.status !== 400) return false;
|
||||||
|
const responseObject = JSON.parse(responseText);
|
||||||
|
const unknownInputErrors = {};
|
||||||
|
if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) {
|
||||||
|
for (const [field, messages] of Object.entries(responseObject.errors)) {
|
||||||
|
const input = form.querySelector(`[name="${field}"]`);
|
||||||
|
if (input) {
|
||||||
|
const parent = input.parentElement;
|
||||||
|
const errorHtml = validationErrorTemplate.render(messages);
|
||||||
|
let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists
|
||||||
|
if (errorContainer) {
|
||||||
|
errorContainer.outerHTML = errorHtml; // Replace existing error container
|
||||||
|
} else {
|
||||||
|
parent.insertAdjacentHTML('beforeend', errorHtml);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unknownInputErrors[field] = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(unknownInputErrors).length > 0) {
|
||||||
|
const html = unknownInputErrorTemplate.render(unknownInputErrors);
|
||||||
|
form.insertAdjacentHTML('beforeend', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
455
OnlyPrompt.Frontend/js/template.js
Normal file
455
OnlyPrompt.Frontend/js/template.js
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
import './linq.js'
|
||||||
|
|
||||||
|
function selectPath(data, expression) {
|
||||||
|
if (!expression) return data;
|
||||||
|
try {
|
||||||
|
return new Function('$data', `with($data) { return (${expression}); }`)(data);
|
||||||
|
} catch {
|
||||||
|
console.error(`Error evaluating Template expression: ${expression}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScopeDict {
|
||||||
|
constructor(initial = {}) {
|
||||||
|
this._entries = { ...initial };
|
||||||
|
}
|
||||||
|
|
||||||
|
set(name, value) {
|
||||||
|
if (name.startsWith('$')) {
|
||||||
|
throw new Error(`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`);
|
||||||
|
}
|
||||||
|
this._entries[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuiltin(name, value) {
|
||||||
|
this._entries[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name) {
|
||||||
|
return name in this._entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name) {
|
||||||
|
return this._entries[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
return new ScopeDict(this._entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScopeProxy(data, scope) {
|
||||||
|
if (data === null || data === undefined || typeof data !== 'object') {
|
||||||
|
scope.setBuiltin('$this', data);
|
||||||
|
return new Proxy({}, {
|
||||||
|
get(_target, prop) {
|
||||||
|
if (prop === '$this') return data;
|
||||||
|
if (scope.has(prop)) return scope.get(prop);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
has(_target, prop) {
|
||||||
|
return prop === '$this' || scope.has(prop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scope.setBuiltin('$this', data);
|
||||||
|
return new Proxy(data, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === '$this') return data;
|
||||||
|
if (scope.has(prop)) return scope.get(prop);
|
||||||
|
return target[prop];
|
||||||
|
},
|
||||||
|
has(target, prop) {
|
||||||
|
return prop === '$this' || scope.has(prop) || prop in target;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipString(str, i) {
|
||||||
|
const quote = str[i];
|
||||||
|
i++;
|
||||||
|
while (i < str.length && str[i] !== quote) {
|
||||||
|
if (str[i] === '\\') i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingParen(str, openPos) {
|
||||||
|
let depth = 1;
|
||||||
|
let i = openPos + 1;
|
||||||
|
while (i < str.length && depth > 0) {
|
||||||
|
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||||
|
i = skipString(str, i) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === '(') depth++;
|
||||||
|
else if (str[i] === ')') depth--;
|
||||||
|
if (depth > 0) i++;
|
||||||
|
}
|
||||||
|
if (depth !== 0) throw new Error('Unclosed ( in template');
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingBrace(str, openPos) {
|
||||||
|
let depth = 1;
|
||||||
|
let i = openPos + 1;
|
||||||
|
while (i < str.length && depth > 0) {
|
||||||
|
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||||
|
i = skipString(str, i) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === '{' && str[i + 1] === '{') {
|
||||||
|
const end = str.indexOf('}}', i + 2);
|
||||||
|
if (end === -1) throw new Error('Unclosed {{ }} in template');
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === '{') depth++;
|
||||||
|
else if (str[i] === '}') depth--;
|
||||||
|
if (depth > 0) i++;
|
||||||
|
}
|
||||||
|
if (depth !== 0) throw new Error('Unclosed { in template');
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTopLevelComma(str) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
||||||
|
i = skipString(str, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === '(') depth++;
|
||||||
|
else if (str[i] === ')') depth--;
|
||||||
|
else if (str[i] === ',' && depth === 0) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value, spec) {
|
||||||
|
const hasThousands = spec.includes(',');
|
||||||
|
const dotIdx = spec.indexOf('.');
|
||||||
|
|
||||||
|
let decimals = 0;
|
||||||
|
if (dotIdx !== -1) {
|
||||||
|
decimals = spec.length - dotIdx - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = value.toFixed(decimals);
|
||||||
|
|
||||||
|
if (hasThousands) {
|
||||||
|
const parts = result.split('.');
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
result = parts.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spec.includes('.') && !hasThousands) {
|
||||||
|
const padLength = spec.replace(/[^0]/g, '').length;
|
||||||
|
if (padLength > 0) {
|
||||||
|
const isNeg = value < 0;
|
||||||
|
let abs = Math.abs(Math.round(value)).toString();
|
||||||
|
abs = abs.padStart(padLength, '0');
|
||||||
|
result = isNeg ? '-' + abs : abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date, spec) {
|
||||||
|
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
'yyyy': date.getFullYear(),
|
||||||
|
'yy': String(date.getFullYear()).slice(-2),
|
||||||
|
'MM': pad(date.getMonth() + 1),
|
||||||
|
'M': date.getMonth() + 1,
|
||||||
|
'dd': pad(date.getDate()),
|
||||||
|
'd': date.getDate(),
|
||||||
|
'HH': pad(date.getHours()),
|
||||||
|
'H': date.getHours(),
|
||||||
|
'hh': pad(date.getHours() % 12 || 12),
|
||||||
|
'h': date.getHours() % 12 || 12,
|
||||||
|
'mm': pad(date.getMinutes()),
|
||||||
|
'm': date.getMinutes(),
|
||||||
|
'ss': pad(date.getSeconds()),
|
||||||
|
's': date.getSeconds(),
|
||||||
|
'fff': pad(date.getMilliseconds(), 3),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = spec;
|
||||||
|
for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
|
||||||
|
result = result.replaceAll(token, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormatter(formatSpec) {
|
||||||
|
return (value) => {
|
||||||
|
if (value == null) return '';
|
||||||
|
if (value instanceof Date) return formatDate(value, formatSpec);
|
||||||
|
if (typeof value === 'number') return formatNumber(value, formatSpec);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StaticTemplatePart {
|
||||||
|
constructor(text) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(_data) {
|
||||||
|
return this.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VariableTemplatePart {
|
||||||
|
constructor(contextPath, formatter) {
|
||||||
|
this.contextPath = contextPath;
|
||||||
|
this.formatter = formatter || (x => x);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
return this.formatter(selectPath(data, this.contextPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LetTemplatePart {
|
||||||
|
constructor(name, expression) {
|
||||||
|
this.name = name;
|
||||||
|
this.expression = expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
declareScoped(scope, data) {
|
||||||
|
scope.set(this.name, selectPath(data, this.expression));
|
||||||
|
}
|
||||||
|
|
||||||
|
render(_data) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListTemplatePart {
|
||||||
|
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
|
||||||
|
this.varNames = varNames;
|
||||||
|
this.contextPath = contextPath;
|
||||||
|
this.formatter = formatter || (x => x);
|
||||||
|
this.innerTemplate = innerTemplate;
|
||||||
|
this.emptyTemplate = emptyTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
const list = this.formatter(selectPath(data, this.contextPath));
|
||||||
|
const arr = Array.isArray(list) ? list : Array.from(list);
|
||||||
|
|
||||||
|
if (arr.length === 0) {
|
||||||
|
return this.emptyTemplate ? this.emptyTemplate.render(data) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
const count = arr.length;
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
const scope = new ScopeDict();
|
||||||
|
const item = arr[index];
|
||||||
|
if (this.varNames.length === 1) {
|
||||||
|
scope.setBuiltin(this.varNames[0], item);
|
||||||
|
} else {
|
||||||
|
for (let v = 0; v < this.varNames.length; v++) {
|
||||||
|
scope.setBuiltin(this.varNames[v], item[v]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.setBuiltin('$index', index);
|
||||||
|
scope.setBuiltin('$first', index === 0);
|
||||||
|
scope.setBuiltin('$last', index === count - 1);
|
||||||
|
scope.setBuiltin('$even', index % 2 === 0);
|
||||||
|
scope.setBuiltin('$odd', index % 2 === 1);
|
||||||
|
scope.setBuiltin('$count', count);
|
||||||
|
result += this.innerTemplate.render(data, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConditionalTemplatePart {
|
||||||
|
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
|
||||||
|
this.contextPath = contextPath;
|
||||||
|
this.formatter = formatter || (x => x);
|
||||||
|
this.condition = condition;
|
||||||
|
this.trueTemplate = trueTemplate;
|
||||||
|
this.falseTemplate = falseTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
const selectedData = this.formatter(selectPath(data, this.contextPath));
|
||||||
|
if (this.condition(selectedData)) {
|
||||||
|
return this.trueTemplate ? this.trueTemplate.render(data) : '';
|
||||||
|
} else {
|
||||||
|
return this.falseTemplate ? this.falseTemplate.render(data) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Template {
|
||||||
|
constructor(templateString) {
|
||||||
|
if (templateString != null) {
|
||||||
|
this.parts = this._parseBlock(templateString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseBlock(str) {
|
||||||
|
const parts = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < str.length) {
|
||||||
|
// {{ expression }} or {{ expression:format }}
|
||||||
|
if (str[i] === '{' && str[i + 1] === '{') {
|
||||||
|
const end = str.indexOf('}}', i + 2);
|
||||||
|
if (end === -1) throw new Error('Unclosed {{ }}');
|
||||||
|
const content = str.substring(i + 2, end).trim();
|
||||||
|
|
||||||
|
let expression, formatter = null;
|
||||||
|
const colonIdx = content.lastIndexOf(':');
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const possibleFormat = content.substring(colonIdx + 1).trim();
|
||||||
|
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
|
||||||
|
expression = content.substring(0, colonIdx).trim();
|
||||||
|
formatter = createFormatter(possibleFormat);
|
||||||
|
} else {
|
||||||
|
expression = content;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expression = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(new VariableTemplatePart(expression, formatter));
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @for(item of expression){...} @empty{...}
|
||||||
|
if (str.startsWith('@for(', i)) {
|
||||||
|
const parenOpen = i + 4;
|
||||||
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
|
const forClause = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
|
const ofIdx = forClause.indexOf(' of ');
|
||||||
|
if (ofIdx === -1) throw new Error('@for missing "of": @for(item of expression)');
|
||||||
|
const varNames = forClause.substring(0, ofIdx).split(',').map(v => v.trim());
|
||||||
|
const expression = forClause.substring(ofIdx + 4).trim();
|
||||||
|
|
||||||
|
const braceOpen = str.indexOf('{', parenClose + 1);
|
||||||
|
if (braceOpen === -1) throw new Error('@for missing block');
|
||||||
|
const braceClose = findClosingBrace(str, braceOpen);
|
||||||
|
const blockContent = str.substring(braceOpen + 1, braceClose);
|
||||||
|
|
||||||
|
const innerTemplate = new Template(null);
|
||||||
|
innerTemplate.parts = this._parseBlock(blockContent);
|
||||||
|
|
||||||
|
let emptyTemplate = null;
|
||||||
|
let afterBlock = braceClose + 1;
|
||||||
|
|
||||||
|
const remaining = str.substring(afterBlock);
|
||||||
|
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
|
||||||
|
if (emptyMatch) {
|
||||||
|
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
||||||
|
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
||||||
|
const emptyContent = str.substring(emptyBraceOpen + 1, emptyBraceClose);
|
||||||
|
|
||||||
|
emptyTemplate = new Template(null);
|
||||||
|
emptyTemplate.parts = this._parseBlock(emptyContent);
|
||||||
|
afterBlock = emptyBraceClose + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(new ListTemplatePart(varNames, expression, innerTemplate, emptyTemplate));
|
||||||
|
i = afterBlock;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @if(expression){...}@else{...}
|
||||||
|
if (str.startsWith('@if(', i)) {
|
||||||
|
const parenOpen = i + 3;
|
||||||
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
|
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
|
const trueBraceOpen = str.indexOf('{', parenClose + 1);
|
||||||
|
if (trueBraceOpen === -1) throw new Error('@if missing block');
|
||||||
|
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
||||||
|
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
|
||||||
|
|
||||||
|
const trueTemplate = new Template(null);
|
||||||
|
trueTemplate.parts = this._parseBlock(trueContent);
|
||||||
|
|
||||||
|
let falseTemplate = null;
|
||||||
|
let afterBlock = trueBraceClose + 1;
|
||||||
|
|
||||||
|
const remaining = str.substring(afterBlock);
|
||||||
|
const elseMatch = remaining.match(/^\s*@else\s*\{/);
|
||||||
|
if (elseMatch) {
|
||||||
|
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
||||||
|
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
||||||
|
const falseContent = str.substring(falseBraceOpen + 1, falseBraceClose);
|
||||||
|
|
||||||
|
falseTemplate = new Template(null);
|
||||||
|
falseTemplate.parts = this._parseBlock(falseContent);
|
||||||
|
afterBlock = falseBraceClose + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(new ConditionalTemplatePart(expression, val => !!val, trueTemplate, falseTemplate));
|
||||||
|
i = afterBlock;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @let name = expression (ends at newline or end of string)
|
||||||
|
if (str.startsWith('@let ', i)) {
|
||||||
|
const lineEnd = str.indexOf('\n', i);
|
||||||
|
const end = lineEnd === -1 ? str.length : lineEnd;
|
||||||
|
const declaration = str.substring(i + 5, end).trim();
|
||||||
|
const eqIdx = declaration.indexOf('=');
|
||||||
|
if (eqIdx === -1) throw new Error('Invalid @let: missing =');
|
||||||
|
|
||||||
|
const name = declaration.substring(0, eqIdx).trim();
|
||||||
|
const expression = declaration.substring(eqIdx + 1).trim();
|
||||||
|
|
||||||
|
parts.push(new LetTemplatePart(name, expression));
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static text — collect until next special token
|
||||||
|
let end = i;
|
||||||
|
while (end < str.length) {
|
||||||
|
if (str[end] === '{' && str[end + 1] === '{') break;
|
||||||
|
if (str[end] === '@' && (
|
||||||
|
str.startsWith('@for(', end) ||
|
||||||
|
str.startsWith('@if(', end) ||
|
||||||
|
str.startsWith('@let ', end)
|
||||||
|
)) break;
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end > i) {
|
||||||
|
parts.push(new StaticTemplatePart(str.substring(i, end)));
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(data, parentScope = new ScopeDict()) {
|
||||||
|
const scope = parentScope.clone();
|
||||||
|
const proxy = createScopeProxy(data, scope);
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (const part of this.parts) {
|
||||||
|
part.declareScoped?.(scope, proxy);
|
||||||
|
result += part.render(proxy);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
OnlyPrompt.Frontend/js/test-template.mjs
Normal file
57
OnlyPrompt.Frontend/js/test-template.mjs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Template } from './template.js';
|
||||||
|
|
||||||
|
// 1. Basic variable
|
||||||
|
let t = new Template('Hello {{ name }}!');
|
||||||
|
console.log('1:', t.render({ name: 'World' }));
|
||||||
|
|
||||||
|
// 2. Format specifier
|
||||||
|
t = new Template('Price: {{ price:0.00 }}');
|
||||||
|
console.log('2:', t.render({ price: 9.5 }));
|
||||||
|
|
||||||
|
// 3. @let
|
||||||
|
t = new Template('@let short = user.name\nHello {{ short }}!');
|
||||||
|
console.log('3:', t.render({ user: { name: 'Alice' } }));
|
||||||
|
|
||||||
|
// 4. @for with separator via @if(!$first)
|
||||||
|
t = new Template('@for(item of items){@if(!$first){, }{{ item }}}');
|
||||||
|
console.log('4:', t.render({ items: ['a', 'b', 'c'] }));
|
||||||
|
|
||||||
|
// 5/6. @if / @else
|
||||||
|
t = new Template('@if(admin){Admin}@else{User}');
|
||||||
|
console.log('5:', t.render({ admin: true }));
|
||||||
|
console.log('6:', t.render({ admin: false }));
|
||||||
|
|
||||||
|
// 7. Expression
|
||||||
|
t = new Template('{{ name.toUpperCase() }}');
|
||||||
|
console.log('7:', t.render({ name: 'alice' }));
|
||||||
|
|
||||||
|
// 8. $index
|
||||||
|
t = new Template('@for(item of items){ {{ $index }}: {{ item }} }');
|
||||||
|
console.log('8:', t.render({ items: ['x', 'y'] }));
|
||||||
|
|
||||||
|
// 9. Multiple @let
|
||||||
|
t = new Template('@let a = first\n@let b = second\n{{ a }} and {{ b }}');
|
||||||
|
console.log('9:', t.render({ first: 'one', second: 'two' }));
|
||||||
|
|
||||||
|
// 10. Nested for
|
||||||
|
t = new Template('@for(group of groups){ @for(item of group.items){ {{ item }} } }');
|
||||||
|
console.log('10:', t.render({ groups: [{ items: [1, 2] }, { items: [3, 4] }] }));
|
||||||
|
|
||||||
|
// 11. @empty block
|
||||||
|
t = new Template('@for(item of items){ {{ item }} } @empty{No items}');
|
||||||
|
console.log('11a:', t.render({ items: [] }));
|
||||||
|
console.log('11b:', t.render({ items: ['x'] }));
|
||||||
|
|
||||||
|
// 12. $first, $last, $even, $odd, $count
|
||||||
|
t = new Template('@for(item of items){[{{ item }} first={{ $first }} last={{ $last }} even={{ $even }} odd={{ $odd }} count={{ $count }}]}');
|
||||||
|
console.log('12:', t.render({ items: ['a', 'b', 'c'] }));
|
||||||
|
|
||||||
|
|
||||||
|
// 13. $this to access the contexts root object
|
||||||
|
t = new Template('@for(item of $this){ {{ item }} } @empty{No items}');
|
||||||
|
console.log('13a:', t.render([]));
|
||||||
|
console.log('13b:', t.render(['x']));
|
||||||
|
|
||||||
|
// 14. @for with multiple variables (destructuring)
|
||||||
|
t = new Template('@for(key, value of Object.entries(obj)){@if(!$first){, }{{ key }}={{ value }}}');
|
||||||
|
console.log('14:', t.render({ obj: { a: 1, b: 2, c: 3 } }));
|
||||||
@ -33,14 +33,14 @@
|
|||||||
<p class="login-subtitle">Welcome back! Enter your details below.</p>
|
<p class="login-subtitle">Welcome back! Enter your details below.</p>
|
||||||
|
|
||||||
<!-- Login form, id is used for JavaScript validation -->
|
<!-- Login form, id is used for JavaScript validation -->
|
||||||
<form id="loginForm" class="login-form">
|
<form id="loginForm" class="login-form" action="/api/v1/auth/login?redirect=/dashboard" method="post">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email Address</label>
|
<label for="email">Email Address</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="userNameOrEmail"
|
||||||
placeholder="yourname@email.com"
|
placeholder="yourname@email.com"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@ -72,8 +72,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script type="module" src="js/login.js"></script>
|
||||||
|
|
||||||
<!-- Script for login logic and form validation -->
|
|
||||||
<script src="../js/login.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user