diff --git a/api/OED.Api/Core/Interfaces/Services/ISessionService.cs b/api/OED.Api/Core/Interfaces/Services/ISessionService.cs new file mode 100644 index 0000000..aed2b2a --- /dev/null +++ b/api/OED.Api/Core/Interfaces/Services/ISessionService.cs @@ -0,0 +1,10 @@ +using OED.Api.Core.Models.Eve; + +namespace OED.Api.Core.Interfaces.Services; + +public interface ISessionService +{ + Task CreateAsync(EveSession session); + Task GetAsync(string sessionId); + Task DeleteAsync(string sessionId); +} diff --git a/api/OED.Api/Core/Interfaces/Services/ITokenStore.cs b/api/OED.Api/Core/Interfaces/Services/ITokenStore.cs new file mode 100644 index 0000000..385e2f5 --- /dev/null +++ b/api/OED.Api/Core/Interfaces/Services/ITokenStore.cs @@ -0,0 +1,9 @@ +namespace OED.Api.Core.Interfaces.Services; + +public interface ITokenStore +{ + Task SetAccessTokenAsync(long characterId, string token, TimeSpan expiry); + Task GetAccessTokenAsync(long characterId); + Task SetRefreshTokenAsync(long characterId, string token); + Task GetRefreshTokenAsync(long characterId); +} diff --git a/api/OED.Api/Core/Models/Eve/EveCharacter.cs b/api/OED.Api/Core/Models/Eve/EveCharacter.cs new file mode 100644 index 0000000..af3cbc5 --- /dev/null +++ b/api/OED.Api/Core/Models/Eve/EveCharacter.cs @@ -0,0 +1,11 @@ +namespace OED.Api.Core.Models.Eve; + +public record EveCharacter +{ + public long CharacterId { get; init; } + public string CharacterName { get; init; } = string.Empty; + public string[] Scopes { get; init; } = []; + public string AccessToken { get; init; } = string.Empty; + public string RefreshToken { get; init; } = string.Empty; + public DateTimeOffset AccessTokenExpiry { get; init; } +} diff --git a/api/OED.Api/Core/Models/Eve/EveSession.cs b/api/OED.Api/Core/Models/Eve/EveSession.cs new file mode 100644 index 0000000..3dc293b --- /dev/null +++ b/api/OED.Api/Core/Models/Eve/EveSession.cs @@ -0,0 +1,8 @@ +namespace OED.Api.Core.Models.Eve; + +public record EveSession +{ + public long CharacterId { get; init; } + public string CharacterName { get; init; } = string.Empty; + public string[] Scopes { get; init; } = []; +} diff --git a/api/OED.Api/Endpoints/AuthEndpoints.cs b/api/OED.Api/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..162e7f7 --- /dev/null +++ b/api/OED.Api/Endpoints/AuthEndpoints.cs @@ -0,0 +1,148 @@ +using OED.Api.Core.Interfaces.Services; +using OED.Api.Core.Models.Eve; +using OED.Api.Infrastructure.Auth; +using OED.Api.Infrastructure.Esi; + +namespace OED.Api.Endpoints; + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(this WebApplication app) + { + app.MapGet("/auth/login", Login); + app.MapGet("/auth/callback", Callback); + app.MapGet("/auth/me", Me); + app.MapPost("/auth/logout", Logout); + } + + private static async Task Login(IConfiguration config, IStateStore stateStore) + { + var clientId = config["Eve:ClientId"]!; + var redirectUri = Uri.EscapeDataString(config["Eve:CallbackUrl"]!); + var scopes = Uri.EscapeDataString("esi-markets.structure_markets.v1"); + var state = await stateStore.GenerateAsync(); + + var url = "https://login.eveonline.com/v2/oauth/authorize" + + $"?response_type=code" + + $"&redirect_uri={redirectUri}" + + $"&client_id={clientId}" + + $"&scope={scopes}" + + $"&state={state}"; + + return Results.Redirect(url); + } + + private static async Task Callback( + HttpContext context, + string code, + string state, + IConfiguration config, + IHttpClientFactory httpClientFactory, + EveJwtValidator jwtValidator, + ISessionService sessionService, + ITokenStore tokenStore, + IStateStore stateStore, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("AuthEndpoints"); + logger.LogInformation("Callback hit. Code: {Code}, State: {State}", code, state); + + var frontendUrl = config["Frontend:Url"]!; + + // Validate and immediately consume the state — prevents replay + if (!await stateStore.ValidateAndConsumeAsync(state)) + { + logger.LogWarning("State validation failed for state: {State}", state); + return Results.Redirect($"{frontendUrl}/?error=invalid_state"); + } + + var clientId = config["Eve:ClientId"]!; + var secretKey = config["Eve:SecretKey"]!; + + var credentials = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes($"{clientId}:{secretKey}") + ); + + var client = httpClientFactory.CreateClient(); + var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://login.eveonline.com/v2/oauth/token"); + tokenRequest.Headers.Add("Authorization", $"Basic {credentials}"); + tokenRequest.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code + }); + + var tokenResponse = await client.SendAsync(tokenRequest); + if (!tokenResponse.IsSuccessStatusCode) + return Results.Redirect($"{frontendUrl}/?error=token_exchange_failed"); + + var tokens = await tokenResponse.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Empty response from EVE SSO"); + + // Validate JWT — throws if tampered, expired, or wrong issuer + var jwt = await jwtValidator.ValidateAsync(tokens.AccessToken, clientId); + + var sub = jwt.Subject; // "CHARACTER:EVE:123456789" + var characterId = long.Parse(sub.Split(':')[2]); + var characterName = jwt.Claims.First(c => c.Type == "name").Value; + var scopes = jwt.Claims + .Where(c => c.Type == "scp") + .Select(c => c.Value) + .ToArray(); + + await tokenStore.SetAccessTokenAsync( + characterId, + tokens.AccessToken, + TimeSpan.FromSeconds(tokens.ExpiresIn - 30) + ); + await tokenStore.SetRefreshTokenAsync(characterId, tokens.RefreshToken); + + var session = new EveSession + { + CharacterId = characterId, + CharacterName = characterName, + Scopes = scopes + }; + + var sessionId = await sessionService.CreateAsync(session); + + context.Response.Cookies.Append("session", sessionId, new CookieOptions + { + HttpOnly = true, + Secure = false, + SameSite = SameSiteMode.Lax, + Domain = "localhost", + Expires = DateTimeOffset.UtcNow.AddDays(7) + }); + + logger.LogInformation("Session created: {SessionId}, redirecting to {Url}", sessionId, frontendUrl); + + return Results.Redirect($"{frontendUrl}/auth/callback?success=true"); + } + + private static IResult Me(HttpContext context) + { + var session = context.Items["Session"] as EveSession; + if (session is null) return Results.Unauthorized(); + + return Results.Ok(new + { + session.CharacterId, + session.CharacterName, + session.Scopes + }); + } + + private static async Task Logout( + HttpContext context, + ISessionService sessionService) + { + if (context.Request.Cookies.TryGetValue("session", out var sessionId)) + { + await sessionService.DeleteAsync(sessionId); + context.Response.Cookies.Delete("session"); + } + + return Results.Ok(); + } +} diff --git a/api/OED.Api/Infrastructure/Auth/EveJwtValidator.cs b/api/OED.Api/Infrastructure/Auth/EveJwtValidator.cs new file mode 100644 index 0000000..f00d732 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/EveJwtValidator.cs @@ -0,0 +1,36 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace OED.Api.Infrastructure.Auth; + +public class EveJwtValidator +{ + private const string MetadataAddress = "https://login.eveonline.com/.well-known/oauth-authorization-server"; + private readonly ConfigurationManager _configManager = new( + MetadataAddress, + new OpenIdConnectConfigurationRetriever() + ); + + public async Task ValidateAsync(string token, string clientId) + { + var config = await _configManager.GetConfigurationAsync(); + + var parameters = new TokenValidationParameters + { + ValidIssuer = "https://login.eveonline.com", + ValidAudiences = [clientId, "EVE Online"], + IssuerSigningKeys = config.SigningKeys, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidateAudience = true, + ValidateIssuer = true, + }; + + var handler = new JwtSecurityTokenHandler(); + handler.ValidateToken(token, parameters, out var validatedToken); + + return (JwtSecurityToken)validatedToken; + } +} diff --git a/api/OED.Api/Infrastructure/Auth/SessionMiddleware.cs b/api/OED.Api/Infrastructure/Auth/SessionMiddleware.cs new file mode 100644 index 0000000..f86a342 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/SessionMiddleware.cs @@ -0,0 +1,23 @@ +using OED.Api.Core.Interfaces.Services; + +namespace OED.Api.Infrastructure.Auth; + +// Runs on every request — reads the session cookie and stamps CharacterId onto HttpContext.Items +public class SessionMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, ISessionService sessionService) + { + if (context.Request.Cookies.TryGetValue("session", out var sessionId)) + { + var session = await sessionService.GetAsync(sessionId); + if (session is not null) + { + context.Items["CharacterId"] = session.CharacterId; + context.Items["CharacterName"] = session.CharacterName; + context.Items["Session"] = session; + } + } + + await next(context); + } +} diff --git a/api/OED.Api/Infrastructure/Auth/SessionService.cs b/api/OED.Api/Infrastructure/Auth/SessionService.cs new file mode 100644 index 0000000..a6fdb02 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/SessionService.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using OED.Api.Core.Interfaces.Services; +using OED.Api.Core.Models.Eve; +using StackExchange.Redis; + +namespace OED.Api.Infrastructure.Auth; + +public class SessionService(IConnectionMultiplexer redis) : ISessionService +{ + private readonly IDatabase _db = redis.GetDatabase(); + private static readonly TimeSpan SessionTtl = TimeSpan.FromDays(7); + + public async Task CreateAsync(EveSession session) + { + var sessionId = Guid.NewGuid().ToString("N"); + var json = JsonSerializer.Serialize(session); + await _db.StringSetAsync($"session:{sessionId}", json, SessionTtl); + return sessionId; + } + + public async Task GetAsync(string sessionId) + { + var json = await _db.StringGetAsync($"session:{sessionId}"); + if (json.IsNullOrEmpty) return null; + return JsonSerializer.Deserialize((string)json!); + } + + public async Task DeleteAsync(string sessionId) + { + await _db.KeyDeleteAsync($"session:{sessionId}"); + } +} diff --git a/api/OED.Api/Infrastructure/Auth/StateStore.cs b/api/OED.Api/Infrastructure/Auth/StateStore.cs new file mode 100644 index 0000000..70d2169 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/StateStore.cs @@ -0,0 +1,28 @@ +using StackExchange.Redis; + +namespace OED.Api.Infrastructure.Auth; + +public interface IStateStore +{ + Task GenerateAsync(); + Task ValidateAndConsumeAsync(string state); +} + +public class StateStore(IConnectionMultiplexer redis) : IStateStore +{ + private readonly IDatabase _db = redis.GetDatabase(); + private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(10); + + public async Task GenerateAsync() + { + var state = Guid.NewGuid().ToString("N"); + await _db.StringSetAsync($"oauth:state:{state}", "1", StateTtl); + return state; + } + + public async Task ValidateAndConsumeAsync(string state) + { + // Delete returns true only if the key existed — atomic check + delete in one operation + return await _db.KeyDeleteAsync($"oauth:state:{state}"); + } +} diff --git a/api/OED.Api/Infrastructure/Auth/TokenEcryptor.cs b/api/OED.Api/Infrastructure/Auth/TokenEcryptor.cs new file mode 100644 index 0000000..47af1f2 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/TokenEcryptor.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace OED.Api.Infrastructure.Auth; + +public interface ITokenEncryptor +{ + string Encrypt(string plaintext); + string Decrypt(string ciphertext); +} + +// Uses ASP.NET Data Protection — handles key management, rotation, and storage automatically +public class TokenEncryptor(IDataProtectionProvider provider) : ITokenEncryptor +{ + private readonly IDataProtector _protector = provider.CreateProtector("Eve.RefreshToken.v1"); + + public string Encrypt(string plaintext) => _protector.Protect(plaintext); + public string Decrypt(string ciphertext) => _protector.Unprotect(ciphertext); +} diff --git a/api/OED.Api/Infrastructure/Auth/TokenStore.cs b/api/OED.Api/Infrastructure/Auth/TokenStore.cs new file mode 100644 index 0000000..59e8608 --- /dev/null +++ b/api/OED.Api/Infrastructure/Auth/TokenStore.cs @@ -0,0 +1,31 @@ +using OED.Api.Core.Interfaces.Services; +using StackExchange.Redis; + +namespace OED.Api.Infrastructure.Auth; + +public class TokenStore(IConnectionMultiplexer redis, ITokenEncryptor encryptor) : ITokenStore +{ + private readonly IDatabase _db = redis.GetDatabase(); + + public async Task SetAccessTokenAsync(long characterId, string token, TimeSpan expiry) + => await _db.StringSetAsync($"accesstoken:{characterId}", token, expiry); + + public async Task GetAccessTokenAsync(long characterId) + { + var val = await _db.StringGetAsync($"accesstoken:{characterId}"); + return val.IsNullOrEmpty ? null : val.ToString(); + } + + public async Task SetRefreshTokenAsync(long characterId, string token) + { + var encrypted = encryptor.Encrypt(token); + await _db.StringSetAsync($"refreshtoken:{characterId}", encrypted); + } + + public async Task GetRefreshTokenAsync(long characterId) + { + var val = await _db.StringGetAsync($"refreshtoken:{characterId}"); + if (val.IsNullOrEmpty) return null; + return encryptor.Decrypt(val.ToString()); + } +} diff --git a/api/OED.Api/Infrastructure/Esi/EsiAuthHandler.cs b/api/OED.Api/Infrastructure/Esi/EsiAuthHandler.cs new file mode 100644 index 0000000..ea3057b --- /dev/null +++ b/api/OED.Api/Infrastructure/Esi/EsiAuthHandler.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Headers; +using OED.Api.Core.Interfaces.Services; + +namespace OED.Api.Infrastructure.Esi; + +public class EsiAuthHandler( + ITokenStore tokenStore, + IEsiTokenRefreshService refreshService, + IHttpContextAccessor httpContextAccessor +) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var characterId = GetCharacterIdFromSession(); + if (characterId is null) + return await base.SendAsync(request, cancellationToken); + + var accessToken = await tokenStore.GetAccessTokenAsync(characterId.Value); + + if (accessToken is null) + accessToken = await refreshService.RefreshAsync(characterId.Value); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + return await base.SendAsync(request, cancellationToken); + } + + private long? GetCharacterIdFromSession() + { + var context = httpContextAccessor.HttpContext; + // CharacterId is set on the HttpContext by session middleware + return context?.Items["CharacterId"] as long?; + } +} diff --git a/api/OED.Api/Infrastructure/Esi/EsiTokenRefreshService.cs b/api/OED.Api/Infrastructure/Esi/EsiTokenRefreshService.cs new file mode 100644 index 0000000..1d440f6 --- /dev/null +++ b/api/OED.Api/Infrastructure/Esi/EsiTokenRefreshService.cs @@ -0,0 +1,61 @@ +using OED.Api.Core.Interfaces.Services; + +namespace OED.Api.Infrastructure.Esi; + +public interface IEsiTokenRefreshService +{ + Task RefreshAsync(long characterId); +} + +public class EsiTokenRefreshService( + IHttpClientFactory httpClientFactory, + ITokenStore tokenStore, + IConfiguration config +) : IEsiTokenRefreshService +{ + public async Task RefreshAsync(long characterId) + { + var refreshToken = await tokenStore.GetRefreshTokenAsync(characterId) + ?? throw new InvalidOperationException($"No refresh token found for character {characterId}"); + + var clientId = config["Eve:ClientId"]!; + var secretKey = config["Eve:SecretKey"]!; + + var credentials = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes($"{clientId}:{secretKey}") + ); + + var client = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://login.eveonline.com/v2/oauth/token"); + request.Headers.Add("Authorization", $"Basic {credentials}"); + request.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken + }); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Empty token response from EVE SSO"); + + // Always overwrite — EVE may rotate the refresh token + await tokenStore.SetRefreshTokenAsync(characterId, result.RefreshToken); + await tokenStore.SetAccessTokenAsync(characterId, result.AccessToken, TimeSpan.FromSeconds(result.ExpiresIn - 30)); + + return result.AccessToken; + } +} + +public record EsiTokenResponse( + string access_token, + int expires_in, + string token_type, + string refresh_token +) +{ + public string AccessToken => access_token; + public int ExpiresIn => expires_in; + public string RefreshToken => refresh_token; +} diff --git a/api/OED.Api/OED.Api.csproj b/api/OED.Api/OED.Api.csproj index 2ca2c24..0a10fe0 100644 --- a/api/OED.Api/OED.Api.csproj +++ b/api/OED.Api/OED.Api.csproj @@ -7,7 +7,26 @@ - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/api/OED.Api/OED.Api.http b/api/OED.Api/OED.Api.http deleted file mode 100644 index f9b7d1f..0000000 --- a/api/OED.Api/OED.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@OED.Api_HostAddress = http://localhost:5163 - -GET {{OED.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/api/OED.Api/Program.cs b/api/OED.Api/Program.cs index 2ac1aa3..65944de 100644 --- a/api/OED.Api/Program.cs +++ b/api/OED.Api/Program.cs @@ -1,21 +1,51 @@ +using Microsoft.AspNetCore.DataProtection; +using OED.Api.Core.Interfaces.Services; +using OED.Api.Endpoints; +using OED.Api.Infrastructure.Auth; +using OED.Api.Infrastructure.Esi; +using StackExchange.Redis; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -builder.Services.AddAuthorization(); +// Redis +builder.Services.AddSingleton( + ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!) +); -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); +// Data Protection — handles encryption key management automatically +// Keys are stored in the local file system by default in dev +// In production point this at a shared volume or Azure Key Vault +builder.Services.AddDataProtection() + .SetApplicationName("OED.Api"); + +// Auth & session +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddTransient(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddHttpClient(); + +// CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("Frontend", policy => + policy + .WithOrigins(builder.Configuration["Frontend:Url"]!) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + ); +}); var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} +app.UseCors("Frontend"); +app.UseMiddleware(); -app.UseHttpsRedirection(); +app.MapAuthEndpoints(); -app.UseAuthorization(); - -app.Run(); \ No newline at end of file +app.Run(); diff --git a/api/OED.Api/appsettings.Development.json b/api/OED.Api/appsettings.Development.json index 0c208ae..23ad065 100644 --- a/api/OED.Api/appsettings.Development.json +++ b/api/OED.Api/appsettings.Development.json @@ -1,8 +1,14 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Eve": { + "ClientId": "0a2e13f0b0f3426ca8f09af5036978a9", + "SecretKey": "eat_vFwYVybPjD1QkPZM4fXJG2AkrzJO9W8U_10Pqp9", + "CallbackUrl": "http://localhost:5163/auth/callback" + }, + "Frontend": { + "Url": "http://localhost:5173" + }, + "ConnectionStrings": { + "Redis": "localhost:6379,password=devpassword", + "Postgres": "Host=localhost;Port=5432;Database=oed;Username=oed;Password=devpassword" } } diff --git a/apps/web/.idea/.gitignore b/apps/web/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/apps/web/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/apps/web/.idea/codeStyles/Project.xml b/apps/web/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..5055868 --- /dev/null +++ b/apps/web/.idea/codeStyles/Project.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/.idea/codeStyles/codeStyleConfig.xml b/apps/web/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/apps/web/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/apps/web/.idea/inspectionProfiles/Project_Default.xml b/apps/web/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/apps/web/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/apps/web/.idea/modules.xml b/apps/web/.idea/modules.xml new file mode 100644 index 0000000..f589ca3 --- /dev/null +++ b/apps/web/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/web/.idea/prettier.xml b/apps/web/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/apps/web/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/apps/web/.idea/vcs.xml b/apps/web/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/apps/web/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/web/.idea/web.iml b/apps/web/.idea/web.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/apps/web/.idea/web.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..4cdeeca --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/routes/layout.css", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 2f74bf5..a083acd 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -7,9 +7,18 @@ "": { "name": "web", "version": "0.0.1", + "dependencies": { + "@tanstack/svelte-query": "^6.1.0", + "@tanstack/svelte-query-devtools": "^6.0.4", + "clsx": "^2.1.1", + "lucide-svelte": "^0.577.0", + "tailwind-merge": "^3.5.0" + }, "devDependencies": { "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^0.561.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", @@ -19,6 +28,7 @@ "@tailwindcss/vite": "^4.1.18", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.18", + "bits-ui": "^2.16.3", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.14.0", @@ -29,7 +39,9 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.51.0", "svelte-check": "^4.4.2", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", "vite": "^7.3.1", @@ -683,6 +695,34 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -735,11 +775,20 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -750,7 +799,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -761,7 +809,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -771,20 +818,28 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.561.0.tgz", + "integrity": "sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -1169,7 +1224,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1191,7 +1245,6 @@ "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1234,7 +1287,6 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -1268,6 +1320,16 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -1566,6 +1628,60 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/svelte-query": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-6.1.0.tgz", + "integrity": "sha512-iKeMaBalk5NeRvp1Y2LgtG3j9HCeLcTEpfsyM+jL1wASnW/EYdCqyyotMKkOY4SNsnBpPDM5zrmstMn6tOzKFQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^5.25.0" + } + }, + "node_modules/@tanstack/svelte-query-devtools": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query-devtools/-/svelte-query-devtools-6.0.4.tgz", + "integrity": "sha512-jHcpWb8KrL3QxoyBHpceR1tIfZUQB1uCiXjh+rDBrmkw1AMIMnIKANzLoz8eQgIGJC7C2dUZXRvDpUvtLDgRAQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.93.0", + "esm-env": "^1.2.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/svelte-query": "^6.0.18", + "svelte": "^5.25.0" + } + }, "node_modules/@testing-library/svelte-core": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", @@ -1608,7 +1724,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1624,7 +1739,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1633,7 +1747,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -1681,7 +1794,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -1948,7 +2060,6 @@ "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", @@ -2082,9 +2193,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2146,7 +2255,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2166,7 +2274,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -2179,6 +2286,31 @@ "dev": true, "license": "MIT" }, + "node_modules/bits-ui": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz", + "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2247,7 +2379,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2353,6 +2484,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2367,7 +2508,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -2452,7 +2592,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2617,7 +2756,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/espree": { @@ -2655,7 +2793,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2899,6 +3036,13 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2926,7 +3070,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -3299,7 +3442,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -3325,11 +3467,29 @@ "dev": true, "license": "MIT" }, + "node_modules/lucide-svelte": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz", + "integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -3525,7 +3685,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3552,7 +3711,6 @@ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.58.2" }, @@ -3609,7 +3767,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3743,7 +3900,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3760,7 +3916,6 @@ "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -3924,6 +4079,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -4039,6 +4219,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4056,9 +4246,7 @@ "version": "5.53.7", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.7.tgz", "integrity": "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4150,13 +4338,70 @@ "node": ">=4" } }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -4239,6 +4484,23 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4258,7 +4520,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4321,7 +4582,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4432,7 +4692,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -4587,24 +4846,6 @@ } } }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4622,7 +4863,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/apps/web/package.json b/apps/web/package.json index 49bb0cf..17a2fac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,8 @@ "devDependencies": { "@eslint/compat": "^2.0.2", "@eslint/js": "^9.39.2", + "@internationalized/date": "^3.12.0", + "@lucide/svelte": "^0.561.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", @@ -28,6 +30,7 @@ "@tailwindcss/vite": "^4.1.18", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.18", + "bits-ui": "^2.16.3", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.14.0", @@ -38,11 +41,20 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.51.0", "svelte-check": "^4.4.2", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", "vite": "^7.3.1", "vitest": "^4.0.18", "vitest-browser-svelte": "^2.0.2" + }, + "dependencies": { + "@tanstack/svelte-query": "^6.1.0", + "@tanstack/svelte-query-devtools": "^6.0.4", + "clsx": "^2.1.1", + "lucide-svelte": "^0.577.0", + "tailwind-merge": "^3.5.0" } } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index f273cc5..b666c1a 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/apps/web/src/lib/api/auth.ts b/apps/web/src/lib/api/auth.ts new file mode 100644 index 0000000..a4eb72d --- /dev/null +++ b/apps/web/src/lib/api/auth.ts @@ -0,0 +1,29 @@ +const BASE_URL = import.meta.env.VITE_API_URL; + +export interface Me { + characterId: number; + characterName: string; + scopes: string[]; +} + +export async function getMe(): Promise { + const res = await fetch(`${BASE_URL}/auth/me`, { + credentials: 'include', // sends the session cookie + }); + + if (res.status === 401) return null; + if (!res.ok) throw new Error('Failed to fetch session'); + + return res.json(); +} + +export async function logout(): Promise { + await fetch(`${BASE_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); +} + +export function redirectToLogin(): void { + window.location.href = `${BASE_URL}/auth/login`; +} diff --git a/apps/web/src/lib/components/app-sidebar.svelte b/apps/web/src/lib/components/app-sidebar.svelte new file mode 100644 index 0000000..4e18e21 --- /dev/null +++ b/apps/web/src/lib/components/app-sidebar.svelte @@ -0,0 +1,77 @@ + + + + + + + + + + {#snippet child({ props })} +
+
+ +
+
+ OED + Office of Economic Development +
+
+ {/snippet} +
+
+
+
+ + + + + {#if $user} + + {:else} + + + + + + {/if} + + +
\ No newline at end of file diff --git a/apps/web/src/lib/components/nav-main.svelte b/apps/web/src/lib/components/nav-main.svelte new file mode 100644 index 0000000..cfe9467 --- /dev/null +++ b/apps/web/src/lib/components/nav-main.svelte @@ -0,0 +1,28 @@ + + + + {#each items as item (item.title)} + + + {#snippet child({ props })} + {item.title} + {/snippet} + + + {/each} + + diff --git a/apps/web/src/lib/components/nav-user.svelte b/apps/web/src/lib/components/nav-user.svelte new file mode 100644 index 0000000..e45480e --- /dev/null +++ b/apps/web/src/lib/components/nav-user.svelte @@ -0,0 +1,73 @@ + + + + + + + {#snippet child({ props })} + + + + + {userData.characterName.slice(0, 2).toUpperCase()} + + +
+ {userData.characterName} +
+ +
+ {/snippet} +
+ + +
+ + + + {userData.characterName.slice(0, 2).toUpperCase()} + + +
+ {userData.characterName} +
+
+
+ + + + Log out + +
+
+
+
\ No newline at end of file diff --git a/apps/web/src/lib/components/ui/avatar/avatar-fallback.svelte b/apps/web/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..249d4a4 --- /dev/null +++ b/apps/web/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/avatar/avatar-image.svelte b/apps/web/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..2bb9db4 --- /dev/null +++ b/apps/web/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/avatar/avatar.svelte b/apps/web/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..e37214d --- /dev/null +++ b/apps/web/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/apps/web/src/lib/components/ui/avatar/index.ts b/apps/web/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..d06457b --- /dev/null +++ b/apps/web/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from "./avatar.svelte"; +import Image from "./avatar-image.svelte"; +import Fallback from "./avatar-fallback.svelte"; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, +}; diff --git a/apps/web/src/lib/components/ui/button/button.svelte b/apps/web/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..a8296ae --- /dev/null +++ b/apps/web/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/button/index.ts b/apps/web/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/apps/web/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte b/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte new file mode 100644 index 0000000..bdabb55 --- /dev/null +++ b/apps/web/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte new file mode 100644 index 0000000..ece7ad6 --- /dev/null +++ b/apps/web/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/collapsible/collapsible.svelte b/apps/web/src/lib/components/ui/collapsible/collapsible.svelte new file mode 100644 index 0000000..39cdd4e --- /dev/null +++ b/apps/web/src/lib/components/ui/collapsible/collapsible.svelte @@ -0,0 +1,11 @@ + + + diff --git a/apps/web/src/lib/components/ui/collapsible/index.ts b/apps/web/src/lib/components/ui/collapsible/index.ts new file mode 100644 index 0000000..169b479 --- /dev/null +++ b/apps/web/src/lib/components/ui/collapsible/index.ts @@ -0,0 +1,13 @@ +import Root from "./collapsible.svelte"; +import Trigger from "./collapsible-trigger.svelte"; +import Content from "./collapsible-content.svelte"; + +export { + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger, +}; diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000..e0e1971 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..6d9ef85 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,43 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..1e96782 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..433540f --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..aca1f7b --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..04cd110 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..9681c2b --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..274cfef --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..189aef4 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..ce2ad09 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,33 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..90f1b6f --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..7c6e9c6 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..3f06dc4 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..5f49d01 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..f044581 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..cb05344 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..cb4bc62 --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/dropdown-menu/index.ts b/apps/web/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..7850c6a --- /dev/null +++ b/apps/web/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/apps/web/src/lib/components/ui/input/index.ts b/apps/web/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/apps/web/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/apps/web/src/lib/components/ui/input/input.svelte b/apps/web/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..ff1a4c8 --- /dev/null +++ b/apps/web/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/separator/index.ts b/apps/web/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/apps/web/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/apps/web/src/lib/components/ui/separator/separator.svelte b/apps/web/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..f40999f --- /dev/null +++ b/apps/web/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/index.ts b/apps/web/src/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..28d7da1 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/index.ts @@ -0,0 +1,34 @@ +import Root from "./sheet.svelte"; +import Portal from "./sheet-portal.svelte"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/apps/web/src/lib/components/ui/sheet/sheet-close.svelte b/apps/web/src/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-content.svelte b/apps/web/src/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..065fe04 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,60 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-description.svelte b/apps/web/src/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte b/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sheet/sheet-header.svelte b/apps/web/src/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte b/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte b/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte new file mode 100644 index 0000000..f3085a3 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-title.svelte b/apps/web/src/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte b/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/sheet/sheet.svelte b/apps/web/src/lib/components/ui/sheet/sheet.svelte new file mode 100644 index 0000000..5bf9783 --- /dev/null +++ b/apps/web/src/lib/components/ui/sheet/sheet.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/sidebar/constants.ts b/apps/web/src/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..4de4435 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "16rem"; +export const SIDEBAR_WIDTH_MOBILE = "18rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/apps/web/src/lib/components/ui/sidebar/context.svelte.ts b/apps/web/src/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/apps/web/src/lib/components/ui/sidebar/index.ts b/apps/web/src/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..f121800 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..a76dfe1 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..b2e72b6 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..7d6d459 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..d3fe295 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..e8ecdb4 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..2ee7d7c --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,103 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..68604e2 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..c8cd4ff --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..76bd1d9 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..5b0d0aa --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,53 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..704d54f --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..1825182 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/apps/web/src/lib/components/ui/sidebar/sidebar.svelte b/apps/web/src/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..bac55d8 --- /dev/null +++ b/apps/web/src/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/apps/web/src/lib/components/ui/skeleton/index.ts b/apps/web/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/apps/web/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/apps/web/src/lib/components/ui/skeleton/skeleton.svelte b/apps/web/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/apps/web/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/apps/web/src/lib/components/ui/tooltip/index.ts b/apps/web/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..1718604 --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from "./tooltip.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/apps/web/src/lib/components/ui/tooltip/tooltip-content.svelte b/apps/web/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..2662522 --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    +
    diff --git a/apps/web/src/lib/components/ui/tooltip/tooltip-portal.svelte b/apps/web/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000..d234f7d --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/tooltip/tooltip-provider.svelte b/apps/web/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000..8150bef --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/apps/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/components/ui/tooltip/tooltip.svelte b/apps/web/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000..0b0f9ce --- /dev/null +++ b/apps/web/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/web/src/lib/hooks/is-mobile.svelte.ts b/apps/web/src/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..4829c00 --- /dev/null +++ b/apps/web/src/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from "svelte/reactivity"; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile extends MediaQuery { + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } +} diff --git a/apps/web/src/lib/stores/auth.ts b/apps/web/src/lib/stores/auth.ts new file mode 100644 index 0000000..49a3a97 --- /dev/null +++ b/apps/web/src/lib/stores/auth.ts @@ -0,0 +1,5 @@ +import { writable } from 'svelte/store'; +import type { Me } from '$lib/api/auth'; + +export const user = writable(null); +export const authLoading = writable(true); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 0d8eb03..47ce91b 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,9 +1,29 @@ - -{@render children()} + + +
    +
    + + +
    +
    + +
    +
    +
    \ No newline at end of file diff --git a/apps/web/src/routes/+layout.ts b/apps/web/src/routes/+layout.ts new file mode 100644 index 0000000..5ca17c6 --- /dev/null +++ b/apps/web/src/routes/+layout.ts @@ -0,0 +1,14 @@ + +import { getMe } from '$lib/api/auth'; +import { user, authLoading } from '$lib/stores/auth'; + +export const load = async () => { + try { + const me = await getMe(); + user.set(me); + } catch { + user.set(null); + } finally { + authLoading.set(false); + } +}; \ No newline at end of file diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index cc88df0..b6a7596 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,2 +1,6 @@ -

    Welcome to SvelteKit

    -

    Visit svelte.dev/docs/kit to read the documentation

    + + +
    +
    diff --git a/apps/web/src/routes/+page.ts b/apps/web/src/routes/+page.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/routes/auth/callback/+page.svelte b/apps/web/src/routes/auth/callback/+page.svelte new file mode 100644 index 0000000..70f819e --- /dev/null +++ b/apps/web/src/routes/auth/callback/+page.svelte @@ -0,0 +1,15 @@ + + +{#if error} +

    Login failed: {error}

    + Go home +{:else} +

    Completing login...

    +{/if} diff --git a/apps/web/src/routes/auth/callback/+page.ts b/apps/web/src/routes/auth/callback/+page.ts new file mode 100644 index 0000000..4af7066 --- /dev/null +++ b/apps/web/src/routes/auth/callback/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import { getMe } from '$lib/api/auth'; + +export const load = async () => { + throw redirect(302, '/dashboard'); +}; diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..e945bf0 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.svelte @@ -0,0 +1,7 @@ + + +

    Welcome, {$user?.characterName}

    +

    Character ID: {$user?.characterId}

    + diff --git a/apps/web/src/routes/dashboard/+page.ts b/apps/web/src/routes/dashboard/+page.ts new file mode 100644 index 0000000..16befed --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.ts @@ -0,0 +1,15 @@ +// Guard — redirect to home if not logged in +import { redirect } from '@sveltejs/kit'; +import { getMe } from '$lib/api/auth'; + +export const ssr = false; + +export const load = async () => { + const me = await getMe(); + + if (!me){ + throw redirect(302, '/'); + } + + return { me }; +}; diff --git a/apps/web/src/routes/layout.css b/apps/web/src/routes/layout.css index cd67023..6966f13 100644 --- a/apps/web/src/routes/layout.css +++ b/apps/web/src/routes/layout.css @@ -1,3 +1,143 @@ -@import 'tailwindcss'; -@plugin '@tailwindcss/forms'; -@plugin '@tailwindcss/typography'; +@import "tailwindcss"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +:root { + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); +} diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 10c4eeb..be346c1 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -6,7 +6,10 @@ const config = { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() + adapter: adapter(), + alias: { + "@/*": "./src/lib/*", + } } };