- started implementing frontend to backend
This commit is contained in:
parent
3c9f7323ba
commit
6abc36bc9b
@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
|
||||
[AllowAnonymous]
|
||||
[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);
|
||||
if (user is null)
|
||||
@ -46,6 +46,9 @@ namespace OnlyPrompt.Backend.Controllers
|
||||
|
||||
var token = _jwtService.BuildToken(user, out var 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();
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
|
||||
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
|
||||
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/OnlyPrompt.Backend"
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Rewrite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@ -11,6 +14,7 @@ using OnlyPrompt.Backend.Services.Jwt;
|
||||
using OnlyPrompt.Backend.Utils;
|
||||
using Scalar.AspNetCore;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var config = builder.Configuration;
|
||||
@ -25,10 +29,14 @@ builder.Services.AddDbContext<OnlyPromptContext>(opts =>
|
||||
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
|
||||
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
|
||||
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
|
||||
|
||||
builder.Services.AddValidation(opts =>
|
||||
{
|
||||
opts.MaxDepth = 10;
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => {
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
|
||||
{
|
||||
opts.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
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());
|
||||
|
||||
var app = builder.Build();
|
||||
@ -65,10 +76,28 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
var rewrite = new RewriteOptions()
|
||||
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
|
||||
|
||||
app.UseRewriter(rewrite);
|
||||
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.MapFallbackToFile("/login.html");
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
|
||||
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);
|
||||
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>
|
||||
|
||||
<!-- 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">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
name="userNameOrEmail"
|
||||
placeholder="yourname@email.com"
|
||||
required
|
||||
>
|
||||
@ -72,8 +72,7 @@
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="js/login.js"></script>
|
||||
|
||||
<!-- Script for login logic and form validation -->
|
||||
<script src="../js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user