555 lines
21 KiB
PHP
555 lines
21 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require __DIR__ . '/../../includes/bootstrap.php';
|
|
require_admin_login();
|
|
|
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
|
$pageTitle = $id ? 'Edit Project | Admin' : 'New Project | Admin';
|
|
|
|
// bring optional columns up-to-date (idempotent)
|
|
try {
|
|
$cols = pdo()->query("SHOW COLUMNS FROM projects")->fetchAll(PDO::FETCH_ASSOC);
|
|
$colNames = array_map(static fn($c) => (string)($c['Field'] ?? ''), $cols);
|
|
$alter = [];
|
|
|
|
if (!in_array('tech_json', $colNames, true)) $alter[] = "ADD COLUMN tech_json LONGTEXT NULL";
|
|
if (!in_array('links_json', $colNames, true)) $alter[] = "ADD COLUMN links_json LONGTEXT NULL";
|
|
if (!in_array('sort_order', $colNames, true)) $alter[] = "ADD COLUMN sort_order INT NOT NULL DEFAULT 0";
|
|
if (!in_array('slug', $colNames, true)) $alter[] = "ADD COLUMN slug VARCHAR(120) NULL";
|
|
if (!in_array('short_summary', $colNames, true)) $alter[] = "ADD COLUMN short_summary VARCHAR(240) NULL";
|
|
|
|
foreach ($cols as $c) {
|
|
if (($c['Field'] ?? '') !== 'summary') continue;
|
|
$type = strtolower((string)($c['Type'] ?? ''));
|
|
$null = strtoupper((string)($c['Null'] ?? '')) === 'YES' ? 'NULL' : 'NOT NULL';
|
|
if (strpos($type, 'text') === false) $alter[] = "MODIFY summary TEXT {$null}";
|
|
break;
|
|
}
|
|
if ($alter) {
|
|
pdo()->exec("ALTER TABLE projects " . implode(", ", $alter));
|
|
}
|
|
} catch (Throwable $e) {}
|
|
|
|
$project = [
|
|
'slug' => '',
|
|
'title' => '',
|
|
'tag' => '',
|
|
'year' => (int)date('Y'),
|
|
'summary' => '',
|
|
'short_summary' => '',
|
|
'tech_json' => '[]',
|
|
'links_json' => '[]',
|
|
'sort_order' => 0,
|
|
];
|
|
|
|
$activeTab = (isset($_GET['tab']) && strtolower((string)$_GET['tab']) === 'gallery') ? 'gallery' : 'details';
|
|
|
|
if ($id) {
|
|
$st = pdo()->prepare("SELECT id, slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order FROM projects WHERE id = ? LIMIT 1");
|
|
$st->execute([$id]);
|
|
$row = $st->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row) {
|
|
flash_set('danger', 'Project not found.');
|
|
header('Location: /public/admin/projects.php');
|
|
exit;
|
|
}
|
|
$project = $row;
|
|
}
|
|
|
|
$errors = [];
|
|
|
|
function parse_list(string $raw): array {
|
|
$parts = preg_split('/[\r\n,]+/', $raw) ?: [];
|
|
$out = [];
|
|
foreach ($parts as $p) {
|
|
$p = trim((string)$p);
|
|
if ($p !== '') $out[] = $p;
|
|
}
|
|
return array_values(array_unique($out));
|
|
}
|
|
|
|
function parse_links(string $raw): array {
|
|
$lines = preg_split('/\R/', $raw) ?: [];
|
|
$links = [];
|
|
foreach ($lines as $ln) {
|
|
$ln = trim((string)$ln);
|
|
if ($ln === '') continue;
|
|
|
|
// Format: Label | https://...
|
|
$label = $ln;
|
|
$url = '';
|
|
if (strpos($ln, '|') !== false) {
|
|
[$label, $url] = array_map('trim', explode('|', $ln, 2));
|
|
}
|
|
|
|
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) continue;
|
|
if ($label === '') $label = 'Link';
|
|
|
|
$links[] = ['label' => $label, 'url' => $url];
|
|
}
|
|
return $links;
|
|
}
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
if (!csrf_check((string)($_POST['csrf'] ?? ''))) {
|
|
$errors[] = 'CSRF failed. Refresh and try again.';
|
|
}
|
|
|
|
$slugRaw = trim((string)($_POST['slug'] ?? ''));
|
|
$title = trim((string)($_POST['title'] ?? ''));
|
|
$tag = trim((string)($_POST['tag'] ?? ''));
|
|
$year = (int)($_POST['year'] ?? 0);
|
|
$summary = trim((string)($_POST['summary'] ?? ''));
|
|
$shortSummary = trim((string)($_POST['short_summary'] ?? ''));
|
|
$techRaw = (string)($_POST['tech'] ?? '');
|
|
$linksRaw= (string)($_POST['links'] ?? '');
|
|
$sort = (int)($_POST['sort_order'] ?? 0);
|
|
|
|
$slug = project_slugify($slugRaw !== '' ? $slugRaw : $title);
|
|
if ($slug === '' && $title !== '') $slug = project_slugify($title);
|
|
|
|
if ($slug === '' || !preg_match('/^[a-z0-9-]{2,120}$/', $slug)) $errors[] = 'Slug must be letters, numbers, or hyphen (auto-derived from title).';
|
|
if ($title === '' || mb_strlen($title) > 140) $errors[] = 'Title must be 1-140 chars.';
|
|
if ($tag === '' || mb_strlen($tag) > 50) $errors[] = 'Tag must be 1-50 chars.';
|
|
if ($year < 2000 || $year > 2100) $errors[] = 'Year must be 2000-2100.';
|
|
if ($summary === '' || mb_strlen($summary) > 5000) $errors[] = 'Summary is required (max 5000 chars).';
|
|
if ($shortSummary !== '' && mb_strlen($shortSummary) > 240) $errors[] = 'Card summary must be 240 chars or less.';
|
|
|
|
$tech = parse_list($techRaw);
|
|
$links = parse_links($linksRaw);
|
|
|
|
$techJson = json_encode($tech, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$linksJson = json_encode($links, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
if ($slug !== '') {
|
|
$st = pdo()->prepare("SELECT id FROM projects WHERE slug = ? AND id <> ? LIMIT 1");
|
|
$st->execute([$slug, $id]);
|
|
if ($st->fetch()) $errors[] = 'Slug already in use.';
|
|
}
|
|
|
|
if (!$errors) {
|
|
if ($id) {
|
|
$st = pdo()->prepare("UPDATE projects SET slug=?, title=?, tag=?, year=?, summary=?, short_summary=?, tech_json=?, links_json=?, sort_order=? WHERE id=?");
|
|
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort, $id]);
|
|
flash_set('success', 'Project updated.');
|
|
} else {
|
|
$st = pdo()->prepare("INSERT INTO projects (slug, title, tag, year, summary, short_summary, tech_json, links_json, sort_order) VALUES (?,?,?,?,?,?,?,?,?)");
|
|
$st->execute([$slug, $title, $tag, $year, $summary, $shortSummary ?: null, $techJson, $linksJson, $sort]);
|
|
flash_set('success', 'Project created.');
|
|
}
|
|
|
|
header('Location: /public/admin/projects.php');
|
|
exit;
|
|
}
|
|
|
|
// repopulate if validation fails
|
|
$project['slug'] = $slug;
|
|
$project['title'] = $title;
|
|
$project['tag'] = $tag;
|
|
$project['year'] = $year;
|
|
$project['summary'] = $summary;
|
|
$project['short_summary'] = $shortSummary;
|
|
$project['tech_json'] = $techJson;
|
|
$project['links_json'] = $linksJson;
|
|
$project['sort_order'] = $sort;
|
|
}
|
|
|
|
// Prefill textareas
|
|
$techArr = json_decode((string)($project['tech_json'] ?? '[]'), true);
|
|
$techTxt = is_array($techArr) ? implode("\n", $techArr) : '';
|
|
|
|
$linksArr = json_decode((string)($project['links_json'] ?? '[]'), true);
|
|
$linksTxt = '';
|
|
if (is_array($linksArr)) {
|
|
foreach ($linksArr as $l) {
|
|
$lbl = (string)($l['label'] ?? '');
|
|
$url = (string)($l['url'] ?? '');
|
|
if ($url !== '') $linksTxt .= "{$lbl} | {$url}\n";
|
|
}
|
|
}
|
|
$linksTxt = trim($linksTxt);
|
|
$mediaEndpoint = url_path('/public/admin/project_media.php');
|
|
$projectFolder = $id ? project_media_folder($project) : '';
|
|
|
|
include __DIR__ . '/_top.php';
|
|
?>
|
|
|
|
<div class="d-flex align-items-center justify-content-between mt-4 flex-wrap gap-2">
|
|
<h1 class="h3 m-0 brand"><?= $id ? 'Edit Project' : 'New Project' ?></h1>
|
|
<a class="btn btn-outline-light" href="/public/admin/projects.php">Back</a>
|
|
</div>
|
|
|
|
<?php if ($errors): ?>
|
|
<div class="alert alert-danger card-glass border-0 mt-3">
|
|
<ul class="mb-0">
|
|
<?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
|
|
</ul>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="card card-glass border-0 mt-3 p-4">
|
|
<ul class="nav nav-pills gap-2" id="projectTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link <?= $activeTab === 'details' ? 'active' : '' ?>" id="tabDetailsBtn" data-bs-toggle="tab" data-bs-target="#tabDetails" type="button" role="tab" aria-controls="tabDetails" aria-selected="<?= $activeTab === 'details' ? 'true' : 'false' ?>">
|
|
Details
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link <?= $activeTab === 'gallery' ? 'active' : '' ?>" id="tabGalleryBtn" data-bs-toggle="tab" data-bs-target="#tabGallery" type="button" role="tab" aria-controls="tabGallery" aria-selected="<?= $activeTab === 'gallery' ? 'true' : 'false' ?>">
|
|
Gallery
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="tab-content mt-3">
|
|
<div class="tab-pane fade <?= $activeTab === 'details' ? 'show active' : '' ?>" id="tabDetails" role="tabpanel" aria-labelledby="tabDetailsBtn">
|
|
<form method="post">
|
|
<input type="hidden" name="csrf" value="<?= htmlspecialchars(csrf_token()) ?>">
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label text-white-50">Title</label>
|
|
<input class="form-control" name="title" maxlength="140" required
|
|
value="<?= htmlspecialchars((string)$project['title']) ?>">
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<label class="form-label text-white-50">Year</label>
|
|
<input class="form-control" name="year" type="number" min="2000" max="2100" required
|
|
value="<?= (int)$project['year'] ?>">
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<label class="form-label text-white-50">Tag</label>
|
|
<input class="form-control" name="tag" maxlength="50" required
|
|
value="<?= htmlspecialchars((string)$project['tag']) ?>">
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label class="form-label text-white-50">Slug (auto)</label>
|
|
<input class="form-control" name="slug" maxlength="120"
|
|
value="<?= htmlspecialchars((string)($project['slug'] ?? '')) ?>"
|
|
placeholder="my-project-name">
|
|
<div class="form-text text-white-50">Letters, numbers, and hyphen. Lowercase slug is auto-built from the title.</div>
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<label class="form-label text-white-50">Sort order</label>
|
|
<input class="form-control" name="sort_order" type="number" value="<?= (int)($project['sort_order'] ?? 0) ?>">
|
|
</div>
|
|
|
|
<div class="col-12">
|
|
<label class="form-label text-white-50">Summary</label>
|
|
<textarea class="form-control" name="summary" rows="4" required><?= htmlspecialchars((string)$project['summary']) ?></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label text-white-50">Card summary (optional, short preview)</label>
|
|
<textarea class="form-control" name="short_summary" rows="2" maxlength="240"
|
|
placeholder="If blank, the preview will clamp the full summary."><?= htmlspecialchars((string)($project['short_summary'] ?? '')) ?></textarea>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label class="form-label text-white-50">Tech (one per line OR comma separated)</label>
|
|
<textarea class="form-control" name="tech" rows="7" placeholder="PHP MariaDB jQuery"><?= htmlspecialchars($techTxt) ?></textarea>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<label class="form-label text-white-50">Links (one per line): <code>Label | https://url</code></label>
|
|
<textarea class="form-control" name="links" rows="7" placeholder="Repo | https://github.com/..."><?= htmlspecialchars($linksTxt) ?></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 d-flex gap-2">
|
|
<button class="btn btn-violet"><?= $id ? 'Save Changes' : 'Create Project' ?></button>
|
|
<a class="btn btn-outline-light" href="/public/admin/projects.php">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="tab-pane fade <?= $activeTab === 'gallery' ? 'show active' : '' ?>" id="tabGallery" role="tabpanel" aria-labelledby="tabGalleryBtn">
|
|
<?php if (!$id): ?>
|
|
<div class="alert alert-info card-glass border-0 mt-3">
|
|
Save the project first to unlock the media gallery.
|
|
</div>
|
|
<?php else: ?>
|
|
<div id="mediaManager"
|
|
data-project-id="<?= (int)$id ?>"
|
|
data-csrf="<?= htmlspecialchars(csrf_token()) ?>"
|
|
data-endpoint="<?= htmlspecialchars($mediaEndpoint) ?>">
|
|
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
|
|
<div>
|
|
<div class="text-white-50 small">Files live in</div>
|
|
<div class="fw-bold">Project imgs/<?= htmlspecialchars($projectFolder) ?></div>
|
|
</div>
|
|
<div class="text-white-50 small">JPEG, PNG or WebP up to 8MB; re-encoded via GD.</div>
|
|
</div>
|
|
|
|
<div class="row g-3 align-items-stretch">
|
|
<div class="col-lg-5">
|
|
<div id="mediaDrop" class="media-drop rounded-3" role="button" tabindex="0">
|
|
<div class="text-center">
|
|
<div class="fw-semibold">Drag & drop screenshots</div>
|
|
<div class="text-white-50 small">or click to pick files</div>
|
|
</div>
|
|
<input type="file" id="mediaInput" accept="image/*" multiple hidden>
|
|
</div>
|
|
<div class="small text-white-50 mt-2">Paste an image URL and we'll fetch and sanitize it.</div>
|
|
<div class="input-group mt-2">
|
|
<input type="url" class="form-control" id="mediaUrlInput" placeholder="https://example.com/screenshot.jpg">
|
|
<button class="btn btn-outline-light" type="button" id="mediaUrlBtn">Add URL</button>
|
|
</div>
|
|
<div class="small mt-2" id="mediaStatus" aria-live="polite"></div>
|
|
</div>
|
|
<div class="col-lg-7">
|
|
<div class="media-grid" id="mediaGrid" aria-live="polite"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function ($) {
|
|
$(function () {
|
|
const $manager = $("#mediaManager");
|
|
if (!$manager.length) return;
|
|
|
|
const endpoint = $manager.data("endpoint");
|
|
const projectId = $manager.data("project-id");
|
|
const csrf = $manager.data("csrf");
|
|
const $drop = $("#mediaDrop");
|
|
const $input = $("#mediaInput");
|
|
const $grid = $("#mediaGrid");
|
|
const $status = $("#mediaStatus");
|
|
const $urlInput = $("#mediaUrlInput");
|
|
const $urlBtn = $("#mediaUrlBtn");
|
|
|
|
const setStatus = (msg, type) => {
|
|
if (!$status.length) return;
|
|
const cls = type === "error" ? "text-danger" : (type === "success" ? "text-success" : "text-white-50");
|
|
$status.attr("class", "small mt-2 " + cls).text(msg || "");
|
|
};
|
|
|
|
const bytesToSize = (bytes) => {
|
|
if (!bytes) return "0 KB";
|
|
const kb = bytes / 1024;
|
|
if (kb < 1024) return kb.toFixed(1) + " KB";
|
|
return (kb / 1024).toFixed(1) + " MB";
|
|
};
|
|
|
|
const render = (files) => {
|
|
if (!$grid.length) return;
|
|
$grid.empty();
|
|
if (!files || !files.length) {
|
|
$("<div/>", { class: "text-white-50 small" })
|
|
.text("No images yet. Drop files or paste a URL to begin.")
|
|
.appendTo($grid);
|
|
return;
|
|
}
|
|
|
|
files.forEach((f) => {
|
|
const sizeLabel = bytesToSize(f.size || 0);
|
|
const $card = $("<div/>", { class: "media-card" });
|
|
|
|
$("<img/>", { src: f.url, alt: f.name }).appendTo($card);
|
|
|
|
$("<div/>", { class: "media-meta" })
|
|
.html(
|
|
'<div class="fw-semibold small text-truncate">' + f.name + "</div>" +
|
|
'<div class="text-white-50 small">' +
|
|
(f.width || "?") + "x" + (f.height || "?") + " - " + sizeLabel + "</div>"
|
|
)
|
|
.appendTo($card);
|
|
|
|
$("<div/>", { class: "media-actions" })
|
|
.append(
|
|
$("<button/>", {
|
|
type: "button",
|
|
class: "btn btn-sm btn-outline-light",
|
|
text: "Delete"
|
|
}).on("click", function () {
|
|
if (confirm("Remove this image?")) deleteFile(f.name);
|
|
})
|
|
)
|
|
.appendTo($card);
|
|
|
|
$grid.append($card);
|
|
});
|
|
};
|
|
|
|
const fetchList = () => {
|
|
setStatus("Loading images...");
|
|
return $.ajax({
|
|
url: `${endpoint}?action=list&project_id=${encodeURIComponent(projectId)}&csrf=${encodeURIComponent(csrf)}`,
|
|
method: "GET",
|
|
dataType: "json",
|
|
cache: false
|
|
})
|
|
.done((data) => {
|
|
if (!data || data.ok !== true) {
|
|
setStatus((data && data.error) ? data.error : "Unable to load images", "error");
|
|
return;
|
|
}
|
|
render(data.files || []);
|
|
const files = data.files || [];
|
|
setStatus(files.length ? "" : "Drop files or paste a URL to upload.", "muted");
|
|
})
|
|
.fail((xhr) => {
|
|
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Failed to load images";
|
|
setStatus(msg, "error");
|
|
});
|
|
};
|
|
|
|
const uploadFiles = (list) => {
|
|
const files = Array.from(list || []).filter((f) => (f.type || "").startsWith("image/"));
|
|
if (!files.length) {
|
|
setStatus("Only image files are accepted.", "error");
|
|
return;
|
|
}
|
|
setStatus("Uploading...", "muted");
|
|
const form = new FormData();
|
|
form.append("action", "upload");
|
|
form.append("project_id", projectId);
|
|
form.append("csrf", csrf);
|
|
files.slice(0, 10).forEach((f) => form.append("files[]", f));
|
|
|
|
$.ajax({
|
|
url: endpoint,
|
|
method: "POST",
|
|
data: form,
|
|
processData: false,
|
|
contentType: false,
|
|
cache: false,
|
|
dataType: "json"
|
|
})
|
|
.done((data) => {
|
|
if (!data || data.ok !== true) {
|
|
setStatus((data && data.error) ? data.error : "Upload failed", "error");
|
|
return;
|
|
}
|
|
setStatus(`Saved ${data.saved || 0} file(s).`, "success");
|
|
fetchList();
|
|
})
|
|
.fail((xhr) => {
|
|
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Upload failed";
|
|
setStatus(msg, "error");
|
|
});
|
|
};
|
|
|
|
const importUrl = () => {
|
|
const url = ($urlInput.val() || "").trim();
|
|
if (!url) {
|
|
setStatus("Paste an image URL first.", "error");
|
|
return;
|
|
}
|
|
setStatus("Fetching URL...", "muted");
|
|
const form = new FormData();
|
|
form.append("action", "fetch_url");
|
|
form.append("project_id", projectId);
|
|
form.append("csrf", csrf);
|
|
form.append("image_url", url);
|
|
|
|
$.ajax({
|
|
url: endpoint,
|
|
method: "POST",
|
|
data: form,
|
|
processData: false,
|
|
contentType: false,
|
|
cache: false,
|
|
dataType: "json"
|
|
})
|
|
.done((data) => {
|
|
if (!data || data.ok !== true) {
|
|
setStatus((data && data.error) ? data.error : "Import failed", "error");
|
|
return;
|
|
}
|
|
setStatus("Image imported.", "success");
|
|
$urlInput.val("");
|
|
fetchList();
|
|
})
|
|
.fail((xhr) => {
|
|
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Import failed";
|
|
setStatus(msg, "error");
|
|
});
|
|
};
|
|
|
|
const deleteFile = (name) => {
|
|
const form = new FormData();
|
|
form.append("action", "delete");
|
|
form.append("project_id", projectId);
|
|
form.append("csrf", csrf);
|
|
form.append("name", name);
|
|
|
|
$.ajax({
|
|
url: endpoint,
|
|
method: "POST",
|
|
data: form,
|
|
processData: false,
|
|
contentType: false,
|
|
cache: false,
|
|
dataType: "json"
|
|
})
|
|
.done((data) => {
|
|
if (!data || data.ok !== true) {
|
|
setStatus((data && data.error) ? data.error : "Delete failed", "error");
|
|
return;
|
|
}
|
|
setStatus("Image removed.", "success");
|
|
fetchList();
|
|
})
|
|
.fail((xhr) => {
|
|
const msg = (xhr && xhr.responseJSON && xhr.responseJSON.error) || xhr.statusText || "Delete failed";
|
|
setStatus(msg, "error");
|
|
});
|
|
};
|
|
|
|
if ($drop.length && $input.length) {
|
|
$drop.on("dragenter dragover", (e) => {
|
|
e.preventDefault();
|
|
$drop.addClass("is-drag");
|
|
});
|
|
$drop.on("dragleave drop", (e) => {
|
|
e.preventDefault();
|
|
$drop.removeClass("is-drag");
|
|
});
|
|
$drop.on("drop", (e) => {
|
|
e.preventDefault();
|
|
$drop.removeClass("is-drag");
|
|
uploadFiles(e.originalEvent.dataTransfer.files);
|
|
});
|
|
$drop.on("click", () => $input.trigger("click"));
|
|
$drop.on("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
$input.trigger("click");
|
|
}
|
|
});
|
|
$input.on("change", function () {
|
|
uploadFiles(this.files);
|
|
this.value = "";
|
|
});
|
|
}
|
|
|
|
if ($urlBtn.length) $urlBtn.on("click", importUrl);
|
|
if ($urlInput.length) {
|
|
$urlInput.on("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
importUrl();
|
|
}
|
|
});
|
|
}
|
|
|
|
fetchList();
|
|
});
|
|
})(jQuery);
|
|
</script>
|
|
|
|
|
|
<link rel="stylesheet" href="/public/css/edit_project.css">
|
|
<?php include __DIR__ . '/_bottom.php'; ?>
|