diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ebb33b6
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,6 @@
+DEV_MODE=true
+CLIENT_ID=your-fflogs-client-id
+REDIRECT_URI=http://localhost:8080/auth/callback.php
+AUTHORIZE_URI=https://www.fflogs.com/oauth/authorize
+TOKEN_URI=https://www.fflogs.com/oauth/token
+GRAPHQL_URI=https://www.fflogs.com/api/v2/user
diff --git a/.gitignore b/.gitignore
index 6eb1281..1540607 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
.claude/
debug/
cached_logs/
+.env
fflogs-schema.json
-config.php
diff --git a/config.php b/config.php
index 148ba52..29ee653 100644
--- a/config.php
+++ b/config.php
@@ -1,11 +1,51 @@
= 2
+ && (($value[0] === '"' && $value[-1] === '"') || ($value[0] === "'" && $value[-1] === "'"))
+ ) {
+ $value = substr($value, 1, -1);
+ }
+
+ $_ENV[$key] = $value;
+ putenv($key . '=' . $value);
+ }
+}
+
+function env_value(string $key, ?string $default = null): string {
+ $value = $_ENV[$key] ?? getenv($key);
+ if ($value === false || $value === null || $value === '') {
+ if ($default !== null) return $default;
+ throw new RuntimeException('Missing required environment value: ' . $key);
+ }
+ return (string)$value;
+}
+
+function env_bool(string $key, bool $default = false): bool {
+ $value = strtolower(env_value($key, $default ? 'true' : 'false'));
+ return in_array($value, ['1', 'true', 'yes', 'on'], true);
+}
+
+load_env_file(__DIR__ . '/.env');
+
+define('DEV_MODE', env_bool('DEV_MODE'));
+define('CLIENT_ID', env_value('CLIENT_ID'));
+define('REDIRECT_URI', env_value('REDIRECT_URI'));
+define('AUTHORIZE_URI', env_value('AUTHORIZE_URI'));
+define('TOKEN_URI', env_value('TOKEN_URI'));
+define('GRAPHQL_URI', env_value('GRAPHQL_URI'));
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
@@ -19,3 +59,38 @@ function session_start_safe(): void {
session_start();
}
}
+
+function default_return_path(): string {
+ $script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/index.php');
+ $base = rtrim(dirname(dirname($script)), '/');
+ return ($base === '' ? '' : $base) . '/index.php';
+}
+
+function safe_return_path(?string $value): string {
+ $value = trim((string)$value);
+ if ($value === '') return default_return_path();
+
+ $parts = parse_url($value);
+ if ($parts === false) return default_return_path();
+ if (isset($parts['host'])) {
+ $currentHost = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]);
+ if (strtolower($parts['host']) !== $currentHost) return default_return_path();
+ } elseif (str_starts_with($value, '//')) {
+ return default_return_path();
+ }
+
+ $path = $parts['path'] ?? '';
+ if ($path === '') $path = default_return_path();
+ if ($path[0] !== '/') $path = '/' . ltrim($path, '/');
+
+ $query = isset($parts['query']) ? '?' . $parts['query'] : '';
+ return $path . $query;
+}
+
+function current_return_path(): string {
+ return safe_return_path($_SERVER['REQUEST_URI'] ?? null);
+}
+
+function auth_start_href(?string $returnPath = null): string {
+ return 'auth/start.php?return=' . rawurlencode($returnPath ?? current_return_path());
+}
diff --git a/css/planner.css b/css/planner.css
index 00627e9..1dcb3ec 100644
--- a/css/planner.css
+++ b/css/planner.css
@@ -797,6 +797,11 @@
z-index: 1;
}
+.timeline-hit-line--tankbuster {
+ width: 2px;
+ background: rgba(177,112,255,.62);
+}
+
.timeline-player-row .timeline-track {
background-color: rgba(255,255,255,0.015);
}
@@ -874,6 +879,17 @@
background: rgba(224,92,92,.22);
}
+.timeline-boss-action--tankbuster {
+ border-color: rgba(177,112,255,.55);
+ background: rgba(177,112,255,.18);
+ color: #dbc7ff;
+}
+
+.timeline-boss-action--tankbuster:hover {
+ border-color: rgba(177,112,255,.85);
+ background: rgba(177,112,255,.28);
+}
+
.timeline-mitigation {
position: absolute;
top: 6px;
diff --git a/index.php b/index.php
index 14ce54d..46fe07d 100644
--- a/index.php
+++ b/index.php
@@ -2,11 +2,6 @@
require_once __DIR__ . '/config.php';
session_start_safe();
-function auth_start_href(): string {
- $return = $_SERVER['REQUEST_URI'] ?? '/';
- return 'auth/start.php?return=' . urlencode($return);
-}
-
$authenticated = !empty($_SESSION['access_token'])
&& ($_SESSION['token_expires'] ?? 0) > time();
diff --git a/js/planner.js b/js/planner.js
index c746a4e..5e0f560 100644
--- a/js/planner.js
+++ b/js/planner.js
@@ -920,14 +920,14 @@ function renderTimelineHtml(plan) {
const bossActions = layoutBossActions(mechanics, duration);
const laneCount = Math.max(1, ...bossActions.map(item => item.lane + 1));
const hitLines = mechanics.map(m => `
-
+
`).join('');
const bossItems = bossActions.map(({ mechanic: m, left, lane }) => `
-
`).join('');
@@ -1869,6 +1869,7 @@ function aoeEventsToMechanics(aoeEvents, fightStart, phases, players, withMitiga
timestamp: relTs,
phase: phase?.name ?? '',
unmitigatedDamage: avgUnmit,
+ isHeavyTankbuster: !!ev.isHeavyTankbuster,
notes: '',
assignments,
};
@@ -1991,6 +1992,7 @@ async function refreshPlanLanguage(planId) {
...mechanic,
name: match.abilityName ?? mechanic.name,
abilityId: match.abilityId ?? mechanic.abilityId,
+ isHeavyTankbuster: !!match.isHeavyTankbuster,
assignments,
};
});