Fixed site discovery process that did not return all the sites
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
<aside class="sidebar @(_sidebarCollapsed ? "collapsed" : "")">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span class="logo-mark">SP</span>
|
<img class="logo-mark" src="SPToolbox-logo-ico.png" alt="SP Toolbox" />
|
||||||
<span class="logo-text">SP Toolbox</span>
|
<span class="logo-text">SP Toolbox</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]">›</button>
|
<button class="toggle-btn @(_sidebarCollapsed ? "collapsed" : "")" @onclick="ToggleSidebar" title="@T["nav.toggleSidebar"]">›</button>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
@page "/settings"
|
@page "/settings"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IUserSessionService Session
|
@inject IUserSessionService Session
|
||||||
|
@inject IUserContextAccessor UserContext
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject TranslationSource T
|
@inject TranslationSource T
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
<h1 class="page-title">@T["tab.settings"]</h1>
|
<h1 class="page-title">@T["tab.settings"]</h1>
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
<select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
|
<select class="form-select" style="width:160px" @bind="_theme" @bind:after="Save">
|
||||||
<option value="System">@T["settings.theme.system"]</option>
|
<option value="System">@T["settings.theme.system"]</option>
|
||||||
<option value="Light">@T["settings.theme.light"]</option>
|
<option value="Light">@T["settings.theme.light"]</option>
|
||||||
|
<option value="Dark">@T["settings.theme.dark"]</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,14 +40,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
@if (UserContext.Role == UserRole.Admin)
|
||||||
|
{
|
||||||
|
@* MSP branding is shared (global settings file) — only Admins set it for everyone. *@
|
||||||
|
<div class="card">
|
||||||
<div class="card-title">@T["settings.section.branding"]</div>
|
<div class="card-title">@T["settings.section.branding"]</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">@T["settings.logo.title"]</label>
|
<label class="form-label">@T["settings.logo.title"]</label>
|
||||||
<p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</p>
|
<p class="text-muted" style="margin-top:0">@T["settings.logo.description"]</p>
|
||||||
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
|
<LogoUpload Value="_mspLogo" ValueChanged="OnMspLogoChanged" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> }
|
@if (_saved) { <div class="alert alert-success">@T["settings.saved"]</div> }
|
||||||
|
|
||||||
@@ -55,10 +63,10 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
var s = Session.Settings;
|
var s = Session.Settings;
|
||||||
// Reflect the culture actually resolved for this circuit (cookie-driven), not the
|
// Read the persisted language directly — the interactive circuit doesn't reliably inherit
|
||||||
// possibly-not-yet-loaded persisted setting.
|
// ambient CurrentUICulture (see TranslationSource), so reading it here shows the wrong value.
|
||||||
_lang = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "fr" ? "fr" : "en";
|
_lang = s.Lang is "fr" or "en" ? s.Lang : "fr";
|
||||||
_theme = s.Theme is "System" or "Light" ? s.Theme : "System";
|
_theme = s.Theme is "System" or "Light" or "Dark" ? s.Theme : "System";
|
||||||
_autoTakeOwnership = s.AutoTakeOwnership;
|
_autoTakeOwnership = s.AutoTakeOwnership;
|
||||||
_mspLogo = s.MspLogo;
|
_mspLogo = s.MspLogo;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -1,23 +1,31 @@
|
|||||||
using Microsoft.Graph;
|
using Microsoft.Online.SharePoint.TenantAdministration;
|
||||||
using Microsoft.Graph.Models;
|
using Microsoft.SharePoint.Client;
|
||||||
using Microsoft.Kiota.Abstractions;
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
using SharepointToolbox.Web.Core.Models;
|
using SharepointToolbox.Web.Core.Models;
|
||||||
using AppGraphClientFactory = SharepointToolbox.Web.Infrastructure.Auth.GraphClientFactory;
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
namespace SharepointToolbox.Web.Services;
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delegated Graph implementation of <see cref="ISiteDiscoveryService"/>.
|
/// Delegated CSOM implementation of <see cref="ISiteDiscoveryService"/>.
|
||||||
/// Uses the <c>/sites?search=*</c> endpoint, paging through every result.
|
///
|
||||||
/// Requires the delegated <c>Sites.Read.All</c> scope.
|
/// Enumerates every site collection via the SharePoint tenant admin endpoint
|
||||||
|
/// (<c>Tenant.GetSitePropertiesFromSharePointByFilters</c>), paging through all
|
||||||
|
/// results. Requires the signed-in user to be a SharePoint administrator.
|
||||||
|
///
|
||||||
|
/// The Graph <c>/sites?search=*</c> endpoint was deliberately abandoned: it ranks
|
||||||
|
/// by relevance and is capped server-side, so it silently dropped sites and
|
||||||
|
/// returned varying counts run-to-run. <c>/sites/getAllSites</c> is app-only and
|
||||||
|
/// 403s on a delegated user token. The tenant admin enumeration is the only path
|
||||||
|
/// that returns the complete, stable set under the app's delegated auth model.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SiteDiscoveryService : ISiteDiscoveryService
|
public class SiteDiscoveryService : ISiteDiscoveryService
|
||||||
{
|
{
|
||||||
private readonly AppGraphClientFactory _graphClientFactory;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
|
||||||
public SiteDiscoveryService(AppGraphClientFactory graphClientFactory)
|
public SiteDiscoveryService(ISessionManager sessionManager)
|
||||||
{
|
{
|
||||||
_graphClientFactory = graphClientFactory;
|
_sessionManager = sessionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
|
public async Task<IReadOnlyList<SiteInfo>> SearchSitesAsync(
|
||||||
@@ -25,47 +33,52 @@ public class SiteDiscoveryService : ISiteDiscoveryService
|
|||||||
string? query = null,
|
string? query = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var graphClient = await _graphClientFactory.CreateClientAsync(profile);
|
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||||
// "*" is the Graph convention for "return all sites".
|
|
||||||
var search = string.IsNullOrWhiteSpace(query) ? "*" : query!;
|
|
||||||
|
|
||||||
// The typed Sites.GetAsync maps its Search property to OData "$search",
|
// Site enumeration only exists on the tenant admin endpoint.
|
||||||
// which routes "*" through KQL and fails ("'*' is not valid at position 0").
|
var adminProfile = new TenantProfile
|
||||||
// The all-sites wildcard only works via the bare, non-OData "search"
|
|
||||||
// query parameter, so build the request manually.
|
|
||||||
var requestInfo = new RequestInformation
|
|
||||||
{
|
{
|
||||||
HttpMethod = Method.GET,
|
Id = profile.Id,
|
||||||
UrlTemplate = "{+baseurl}/sites{?search,%24top}",
|
Name = profile.Name,
|
||||||
PathParameters = new Dictionary<string, object>
|
TenantUrl = BuildAdminUrl(profile.TenantUrl),
|
||||||
{
|
TenantId = profile.TenantId,
|
||||||
{ "baseurl", graphClient.RequestAdapter.BaseUrl ?? "https://graph.microsoft.com/v1.0" }
|
ClientId = profile.ClientId,
|
||||||
},
|
ClientLogo = profile.ClientLogo,
|
||||||
};
|
};
|
||||||
requestInfo.QueryParameters.Add("search", search);
|
|
||||||
requestInfo.QueryParameters.Add("%24top", 999);
|
|
||||||
requestInfo.Headers.Add("Accept", "application/json");
|
|
||||||
|
|
||||||
var response = await graphClient.RequestAdapter.SendAsync<SiteCollectionResponse>(
|
var ctx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
|
||||||
requestInfo, SiteCollectionResponse.CreateFromDiscriminatorValue, cancellationToken: ct);
|
var tenant = new Tenant(ctx);
|
||||||
|
|
||||||
if (response is null) return Array.Empty<SiteInfo>();
|
var filter = new SPOSitePropertiesEnumerableFilter
|
||||||
|
{
|
||||||
|
IncludeDetail = false,
|
||||||
|
IncludePersonalSite = PersonalSiteFilter.Exclude,
|
||||||
|
StartIndex = null,
|
||||||
|
Template = null,
|
||||||
|
};
|
||||||
|
|
||||||
var results = new List<SiteInfo>();
|
var results = new List<SiteInfo>();
|
||||||
var iter = PageIterator<Site, SiteCollectionResponse>.CreatePageIterator(
|
SPOSitePropertiesEnumerable page;
|
||||||
graphClient, response,
|
do
|
||||||
site =>
|
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) return false;
|
ct.ThrowIfCancellationRequested();
|
||||||
var url = site.WebUrl ?? string.Empty;
|
|
||||||
if (string.IsNullOrEmpty(url)) return true;
|
page = await FetchPageWithColdTokenRetryAsync(ctx, tenant, filter, ct);
|
||||||
// Skip OneDrive personal sites — not useful for these scans.
|
|
||||||
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) return true;
|
foreach (var sp in page)
|
||||||
var title = site.DisplayName ?? site.Name ?? url;
|
{
|
||||||
|
var url = sp.Url ?? string.Empty;
|
||||||
|
if (string.IsNullOrEmpty(url)) continue;
|
||||||
|
// Belt-and-braces: PersonalSiteFilter.Exclude already drops OneDrive.
|
||||||
|
if (url.Contains("/personal/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
var title = string.IsNullOrEmpty(sp.Title) ? url : sp.Title;
|
||||||
results.Add(new SiteInfo(url, title));
|
results.Add(new SiteInfo(url, title));
|
||||||
return true;
|
}
|
||||||
});
|
|
||||||
await iter.IterateAsync(ct);
|
// NextStartIndexFromSharePoint is empty/null once the last page is returned.
|
||||||
|
filter.StartIndex = page.NextStartIndexFromSharePoint;
|
||||||
|
}
|
||||||
|
while (!string.IsNullOrEmpty(filter.StartIndex));
|
||||||
|
|
||||||
return results
|
return results
|
||||||
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(s => s.Url, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -73,4 +86,52 @@ public class SiteDiscoveryService : ISiteDiscoveryService
|
|||||||
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int MaxColdTokenAttempts = 4;
|
||||||
|
private const int BackoffBaseSeconds = 3;
|
||||||
|
|
||||||
|
// The tenant admin endpoint transiently 403s on a cold delegated token (the same
|
||||||
|
// behaviour the elevation coordinator handles): the first call against the admin
|
||||||
|
// host can be denied while the token warms, then clears within seconds. Retry the
|
||||||
|
// admin query on access-denied with backoff. A genuine lack of SharePoint tenant
|
||||||
|
// administrator rights keeps failing and surfaces the enriched 403 after retries —
|
||||||
|
// elevation cannot self-grant tenant-admin, so there is nothing to auto-correct.
|
||||||
|
//
|
||||||
|
// The request (GetSiteProperties + Load) MUST be re-issued inside the loop: a failed
|
||||||
|
// CSOM ExecuteQuery clears the context's pending-request queue, so retrying the
|
||||||
|
// execute alone would run an empty batch, leave the page uninitialized, and throw
|
||||||
|
// "The collection has not been initialized" on iteration.
|
||||||
|
private static async Task<SPOSitePropertiesEnumerable> FetchPageWithColdTokenRetryAsync(
|
||||||
|
ClientContext ctx, Tenant tenant, SPOSitePropertiesEnumerableFilter filter, CancellationToken ct)
|
||||||
|
{
|
||||||
|
for (int attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var page = tenant.GetSitePropertiesFromSharePointByFilters(filter);
|
||||||
|
ctx.Load(page);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
catch (SharePointAccessDeniedException ex) when (attempt < MaxColdTokenAttempts)
|
||||||
|
{
|
||||||
|
var delay = TimeSpan.FromSeconds(BackoffBaseSeconds * attempt);
|
||||||
|
Serilog.Log.Warning(
|
||||||
|
"Tenant admin endpoint denied during site discovery (attempt {N}/{Max}); " +
|
||||||
|
"retrying in {Delay}s. {Err}",
|
||||||
|
attempt, MaxColdTokenAttempts, delay.TotalSeconds, ex.Message);
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://contoso.sharepoint.com[/sites/Foo] → https://contoso-admin.sharepoint.com
|
||||||
|
private static string BuildAdminUrl(string tenantUrl)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
|
||||||
|
return tenantUrl;
|
||||||
|
var adminHost = uri.Host.Replace(".sharepoint.com", "-admin.sharepoint.com",
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
return $"{uri.Scheme}://{adminHost}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
+2
-4
@@ -69,10 +69,8 @@ body {
|
|||||||
}
|
}
|
||||||
.logo { display: flex; align-items: center; gap: 11px; overflow: hidden; }
|
.logo { display: flex; align-items: center; gap: 11px; overflow: hidden; }
|
||||||
.logo-mark {
|
.logo-mark {
|
||||||
width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px;
|
width: 38px; height: 38px; flex-shrink: 0;
|
||||||
background: linear-gradient(135deg, #6d6df0, #5b5bd6);
|
object-fit: contain;
|
||||||
color: #fff; font-weight: 700; font-size: 14px; letter-spacing: .5px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
}
|
||||||
.logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; }
|
.logo-text { font-weight: 700; font-size: 16px; color: var(--sidebar-text); white-space: nowrap; }
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -19,3 +19,193 @@ window.sptb = {
|
|||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Easter eggs: N spaces in a row → slow full-screen fade-in ──
|
||||||
|
(function () {
|
||||||
|
// threshold (consecutive spaces) → image file in wwwroot
|
||||||
|
var EGGS = [
|
||||||
|
{ at: 5, src: 'seb-egg.jpg' },
|
||||||
|
{ at: 10, src: 'easter-egg.jpg' }
|
||||||
|
];
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
function typingInField(t) {
|
||||||
|
if (!t) return false;
|
||||||
|
var tag = t.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reveal(src) {
|
||||||
|
var id = 'sptb-egg-' + src.replace(/[^a-z0-9]/gi, '-');
|
||||||
|
if (document.getElementById(id)) return; // this egg already showing
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.id = id;
|
||||||
|
ov.style.cssText =
|
||||||
|
'position:fixed;inset:0;z-index:99999;background-color:#000;' +
|
||||||
|
'background-image:url("' + src + '");background-size:100% 100%;' +
|
||||||
|
'background-repeat:no-repeat;background-position:center;' +
|
||||||
|
'opacity:0;transition:opacity 10s ease;cursor:pointer';
|
||||||
|
ov.addEventListener('click', function () { ov.remove(); });
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
void ov.offsetWidth; // force reflow so transition runs
|
||||||
|
ov.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', function (e) {
|
||||||
|
var isSpace = e.code === 'Space' || e.key === ' ' || e.keyCode === 32;
|
||||||
|
if (isSpace && !typingInField(e.target)) {
|
||||||
|
count++;
|
||||||
|
for (var i = 0; i < EGGS.length; i++) {
|
||||||
|
if (count === EGGS[i].at) { reveal(EGGS[i].src); break; }
|
||||||
|
}
|
||||||
|
} else if (!isSpace) {
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Easter egg: type "maze" → fake 3-level maze → screamer at the end ──
|
||||||
|
(function () {
|
||||||
|
var TRIGGER = 'maze';
|
||||||
|
var buf = '';
|
||||||
|
|
||||||
|
function typingInField(t) {
|
||||||
|
if (!t) return false;
|
||||||
|
var g = t.tagName;
|
||||||
|
return g === 'INPUT' || g === 'TEXTAREA' || t.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base maze: '#' wall, '.' path, 'P' start, 'G' goal. Solvable snake corridor.
|
||||||
|
var BASE = [
|
||||||
|
'#########',
|
||||||
|
'#P......#',
|
||||||
|
'#######.#',
|
||||||
|
'#.......#',
|
||||||
|
'#.#######',
|
||||||
|
'#.......#',
|
||||||
|
'#######.#',
|
||||||
|
'#......G#',
|
||||||
|
'#########'
|
||||||
|
];
|
||||||
|
function mirrorH(g) { return g.map(function (r) { return r.split('').reverse().join(''); }); }
|
||||||
|
function mirrorV(g) { return g.slice().reverse(); }
|
||||||
|
var LEVELS = [BASE, mirrorH(BASE), mirrorV(BASE)];
|
||||||
|
|
||||||
|
var active = false, lvl = 0, grid = null, px = 0, py = 0, rootEl = null, keyHandler = null;
|
||||||
|
|
||||||
|
function parse(g) {
|
||||||
|
for (var y = 0; y < g.length; y++)
|
||||||
|
for (var x = 0; x < g[y].length; x++)
|
||||||
|
if (g[y][x] === 'P') { px = x; py = y; }
|
||||||
|
}
|
||||||
|
function cellAt(x, y) { return grid[y] ? grid[y][x] : undefined; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var cols = grid[0].length, html = '';
|
||||||
|
for (var y = 0; y < grid.length; y++) {
|
||||||
|
for (var x = 0; x < grid[y].length; x++) {
|
||||||
|
var c = cellAt(x, y);
|
||||||
|
var bg = c === '#' ? '#222' : (c === 'G' ? '#1a7f37' : '#0b0b0b');
|
||||||
|
if (x === px && y === py) bg = '#e63946';
|
||||||
|
html += '<div style="background:' + bg + '"></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var board = rootEl.querySelector('.mz-board');
|
||||||
|
board.style.gridTemplateColumns = 'repeat(' + cols + ',1fr)';
|
||||||
|
board.innerHTML = html;
|
||||||
|
rootEl.querySelector('.mz-lvl').textContent = 'Level ' + (lvl + 1) + ' / 3';
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(dx, dy) {
|
||||||
|
var nx = px + dx, ny = py + dy, c = cellAt(nx, ny);
|
||||||
|
if (c === undefined || c === '#') return;
|
||||||
|
px = nx; py = ny;
|
||||||
|
if (c === 'G') { nextLevel(); return; }
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextLevel() {
|
||||||
|
lvl++;
|
||||||
|
if (lvl >= LEVELS.length) { end(); screamer(); return; }
|
||||||
|
grid = LEVELS[lvl].slice(); parse(grid); render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (active) return;
|
||||||
|
active = true; lvl = 0; grid = LEVELS[0].slice(); parse(grid);
|
||||||
|
rootEl = document.createElement('div');
|
||||||
|
rootEl.id = 'sptb-maze';
|
||||||
|
rootEl.style.cssText = 'position:fixed;inset:0;z-index:99998;background:#000;display:flex;' +
|
||||||
|
'flex-direction:column;align-items:center;justify-content:center;font-family:monospace;color:#ddd;gap:12px';
|
||||||
|
rootEl.innerHTML =
|
||||||
|
'<div class="mz-lvl" style="font-size:18px;letter-spacing:1px"></div>' +
|
||||||
|
'<div class="mz-board" style="display:grid;width:min(80vmin,560px);aspect-ratio:1;gap:2px"></div>' +
|
||||||
|
'<div style="font-size:12px;color:#888">Arrows / WASD to move • Esc to quit</div>';
|
||||||
|
document.body.appendChild(rootEl);
|
||||||
|
render();
|
||||||
|
keyHandler = function (e) {
|
||||||
|
var k = e.key;
|
||||||
|
if (k === 'Escape') { end(); return; }
|
||||||
|
var dx = 0, dy = 0;
|
||||||
|
if (k === 'ArrowUp' || k === 'w' || k === 'W') dy = -1;
|
||||||
|
else if (k === 'ArrowDown' || k === 's' || k === 'S') dy = 1;
|
||||||
|
else if (k === 'ArrowLeft' || k === 'a' || k === 'A') dx = -1;
|
||||||
|
else if (k === 'ArrowRight' || k === 'd' || k === 'D') dx = 1;
|
||||||
|
else return;
|
||||||
|
e.preventDefault(); move(dx, dy);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', keyHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
|
active = false;
|
||||||
|
if (keyHandler) { window.removeEventListener('keydown', keyHandler, true); keyHandler = null; }
|
||||||
|
if (rootEl) { rootEl.remove(); rootEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function screamer() {
|
||||||
|
if (!document.getElementById('sptb-shake-css')) {
|
||||||
|
var st = document.createElement('style'); st.id = 'sptb-shake-css';
|
||||||
|
st.textContent = '@keyframes sptbShake{' +
|
||||||
|
'0%{transform:translate(4px,-4px) scale(1.05)}' +
|
||||||
|
'25%{transform:translate(-5px,3px) scale(1.08)}' +
|
||||||
|
'50%{transform:translate(3px,5px) scale(1.04)}' +
|
||||||
|
'75%{transform:translate(-4px,-3px) scale(1.07)}' +
|
||||||
|
'100%{transform:translate(4px,4px) scale(1.05)}}';
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.style.cssText = 'position:fixed;inset:0;z-index:100000;background:#000 center/cover no-repeat ' +
|
||||||
|
'url("screamer.jpg");animation:sptbShake .05s linear infinite;cursor:pointer';
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
scream();
|
||||||
|
ov.addEventListener('click', function () { ov.remove(); });
|
||||||
|
setTimeout(function () { if (ov.parentNode) ov.remove(); }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scream() {
|
||||||
|
try {
|
||||||
|
var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return;
|
||||||
|
var ac = new AC(), dur = 2.0, n = Math.floor(ac.sampleRate * dur);
|
||||||
|
var b = ac.createBuffer(1, n, ac.sampleRate), d = b.getChannelData(0);
|
||||||
|
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; // white noise
|
||||||
|
var src = ac.createBufferSource(); src.buffer = b;
|
||||||
|
var ng = ac.createGain(); ng.gain.value = 0.9;
|
||||||
|
var osc = ac.createOscillator(); osc.type = 'sawtooth'; osc.frequency.value = 180;
|
||||||
|
var og = ac.createGain(); og.gain.value = 0.4;
|
||||||
|
src.connect(ng).connect(ac.destination);
|
||||||
|
osc.connect(og).connect(ac.destination);
|
||||||
|
src.start(); osc.start();
|
||||||
|
src.stop(ac.currentTime + dur); osc.stop(ac.currentTime + dur);
|
||||||
|
} catch (e) { /* audio blocked — image jump still fires */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', function (e) {
|
||||||
|
if (active) return;
|
||||||
|
if (typingInField(e.target)) { buf = ''; return; }
|
||||||
|
var k = e.key;
|
||||||
|
if (!k || k.length !== 1) return;
|
||||||
|
buf = (buf + k.toLowerCase()).slice(-TRIGGER.length);
|
||||||
|
if (buf === TRIGGER) { buf = ''; start(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Reference in New Issue
Block a user