mirror of
https://github.com/Theodor-Springmann-Stiftung/hamann-ausgabe-core.git
synced 2025-10-29 17:25:32 +00:00
Reworked Publish View
This commit is contained in:
@@ -300,9 +300,6 @@ public class APIController : Controller {
|
||||
continue;
|
||||
}
|
||||
|
||||
filename = XMLFileHelpers.StreamToString(section.Body, ModelState);
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
|
||||
if (hasContentDispositionHeader && contentDisposition != null) {
|
||||
if (!MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition)) {
|
||||
ModelState.AddModelError("Error", $"Wrong Content-Dispostion Headers in Multipart Document");
|
||||
@@ -310,7 +307,7 @@ public class APIController : Controller {
|
||||
}
|
||||
|
||||
filename = XMLFileHelpers.StreamToString(section.Body, ModelState);
|
||||
|
||||
if (!ModelState.IsValid) return BadRequest(ModelState);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -343,6 +340,6 @@ public class APIController : Controller {
|
||||
_xmlProvider.SetInProduction(newFile.First());
|
||||
_xmlService.UnUseProduction();
|
||||
|
||||
return Created("/", null);
|
||||
return Created("/", newFile.First());
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public class XMLProvider : IXMLProvider {
|
||||
|
||||
public async Task<IFileInfo?> SaveHamannFile(XElement element, string basefilepath, ModelStateDictionary ModelState) {
|
||||
var date = DateTime.Now;
|
||||
var filename = "hamann_" + date.Year + "-" + date.Month + "-" + date.Day + ".xml";
|
||||
var filename = "hamann_" + date.Year + "-" + date.Month + "-" + date.Day + "." + Path.GetRandomFileName() + ".xml";
|
||||
var directory = Path.Combine(basefilepath, "hamann");
|
||||
var path = Path.Combine(directory, filename);
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ public class FileList {
|
||||
throw new Exception("Diese Liste kann nur Elemente des Typs " + XMLRoot.Prefix + " enthalten");
|
||||
|
||||
if (_Files == null) _Files = new HashSet<XMLRootDocument>();
|
||||
if (!_Files.Contains(document)) _Files.Add(document);
|
||||
var replacing = _Files.Where(x => x.FileName == document.FileName);
|
||||
if (replacing != null && replacing.Any()) _Files.Remove(replacing.First());
|
||||
_Files.Add(document);
|
||||
}
|
||||
|
||||
public bool Contains(XMLRootDocument doc) {
|
||||
|
||||
@@ -37,16 +37,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="ha-publishform" id="ha-publishform" asp-controller="API" asp-action="LocalPublish" method="post" enctype="multipart/form-data">
|
||||
<label class="ha-publishfilelabel" id="ha-publishfilelabel">
|
||||
<a class="ha-publishbutton" asp-controller="Upload" asp-action="Index" asp-route-id="@string.Empty">
|
||||
<div class="ha-publishtext">Veröffentlichen</div>
|
||||
<div class="ha-lds-ellipsis" id="ha-lds-ellipsis-publish"><div></div><div></div><div></div><div></div></div>
|
||||
</label>
|
||||
<div class="ha-publishmessage" id="ha-publishmessage">
|
||||
@* Fehler!<br/> *@
|
||||
<output form="uploadForm" name="publish-result" id="publish-result"></output>
|
||||
</div>
|
||||
</form>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +75,10 @@
|
||||
|
||||
@* Start Page File List *@
|
||||
else {
|
||||
<div class="ha-publishfilelist">
|
||||
@await Html.PartialAsync("/Views/Shared/_PublishForm.cshtml", Model)
|
||||
</div>
|
||||
|
||||
<div class="ha-hamannfilechooser">
|
||||
@await Html.PartialAsync("/Views/Shared/_FileList.cshtml", (Model.HamannFiles, "Verfügbare Hamann-Dateien", "API", "SetUsedHamann", string.Empty, "/Download/XML/", false))
|
||||
</div>
|
||||
@@ -110,7 +107,6 @@ else {
|
||||
"use strict";
|
||||
const hideshowfiles = function() {
|
||||
let elem = document.getElementById("ha-availablefileslist");
|
||||
console.log("hello!");
|
||||
if (elem.classList.contains('hidden')) {
|
||||
|
||||
elem.classList.remove('hidden');
|
||||
@@ -147,39 +143,6 @@ else {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const LOCALPUBLISHSubmit = async function (oFormElement) {
|
||||
var fd = new FormData();
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "none";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "inline-block";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "0";
|
||||
await fetch(oFormElement.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': getCookie('RequestVerificationToken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
if ("Error" in json) {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "1";
|
||||
document.getElementById("publish-result").value = json.Error;
|
||||
} else {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "1";
|
||||
document.getElementById("publish-result").value = "Erfolg!";
|
||||
window.location.replace("/Admin/Upload/");
|
||||
}
|
||||
})
|
||||
.catch ((e) => {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("publish-result").value = "Keine Antwort. Bitte Seite neu laden!";
|
||||
})
|
||||
}
|
||||
|
||||
const UPLOADSubmit = async function (oFormElement, file = null) {
|
||||
var fd = new FormData();
|
||||
if (file !== null) fd.append("file", file);
|
||||
@@ -230,12 +193,11 @@ else {
|
||||
var submitelement = document.getElementById("file");
|
||||
var formelement = document.getElementById("uploadForm");
|
||||
var dropzone = document.getElementById("dropzone");
|
||||
var publishelement = document.getElementById("ha-publishform");
|
||||
var publishbutton = document.getElementById("ha-publishfilelabel");
|
||||
|
||||
var filesbutton = document.getElementById("ha-availablefiles");
|
||||
if (filesbutton !== null)
|
||||
filesbutton.addEventListener("click", () => hideshowfiles());
|
||||
publishbutton.addEventListener("click", () => LOCALPUBLISHSubmit(publishelement));
|
||||
|
||||
submitelement.addEventListener("change", () => UPLOADSubmit(formelement));
|
||||
dropzone.addEventListener("drop", (ev) => dropHandler(formelement, ev, dropzone));
|
||||
dropzone.addEventListener("dragover", (ev) => dragOverHandler(ev, dropzone));
|
||||
|
||||
@@ -37,11 +37,11 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<input class="btn ha-filelistbutton" type="submit" value="Laden" />
|
||||
<output id ="ha-filelistoutput"></output><input class="btn ha-filelistbutton" type="submit" value="Laden" />
|
||||
</form>
|
||||
}
|
||||
else {
|
||||
<div>Keine Hamann-Dateien gefunden! Es wird eine fallback-Datei verwendet!</div>
|
||||
<div>Keine Dateien gefunden! Es wird eine fallback-Datei verwendet!</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
@@ -58,14 +58,16 @@
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
if ("Error" in json) {
|
||||
|
||||
document.getElementById("ha-filelistoutput").textContent = json.Error;
|
||||
}
|
||||
else {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch ((e) => {
|
||||
location.reload();
|
||||
document.getElementById("ha-filelistoutput").textContent = e;
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
76
HaWeb/Views/Shared/_PublishForm.cshtml
Normal file
76
HaWeb/Views/Shared/_PublishForm.cshtml
Normal file
@@ -0,0 +1,76 @@
|
||||
@model UploadViewModel;
|
||||
@if (Model.UsedFiles != null && Model.UsedFiles.Any()) {
|
||||
<div class="ha-publishfilelisttitle">Aktuell geladene Dateien</div>
|
||||
<table class="ha-publishfilelistlist">
|
||||
@foreach (var (category, files) in Model.UsedFiles.OrderBy(x => x.Key))
|
||||
{
|
||||
<tr>
|
||||
<td>@Model.AvailableRoots.Where(x => x.Prefix == category).First().Type:</td>
|
||||
<td>
|
||||
@foreach (var item in files)
|
||||
{
|
||||
if (item != files.Last()) {
|
||||
<span>@item.FileName,</span>
|
||||
}
|
||||
else {
|
||||
<span>@item.FileName</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
|
||||
<form class="ha-publishform" id="ha-publishform" asp-controller="API" asp-action="LocalPublish" method="post" enctype="multipart/form-data">
|
||||
<label class="ha-publishfilelabel" id="ha-publishfilelabel">
|
||||
<div class="ha-publishtext">Dateien Veröffentlichen</div>
|
||||
<div class="ha-lds-ellipsis" id="ha-lds-ellipsis-publish"><div></div><div></div><div></div><div></div></div>
|
||||
</label>
|
||||
<div class="ha-publishmessage" id="ha-publishmessage">
|
||||
@* Fehler!<br/> *@
|
||||
<output form="uploadForm" name="publish-result" id="publish-result"></output>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
<script>
|
||||
const LOCALPUBLISHSubmit = async function (oFormElement) {
|
||||
var fd = new FormData();
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "none";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "inline-block";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "0";
|
||||
await fetch(oFormElement.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'RequestVerificationToken': getCookie('RequestVerificationToken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
if ("Error" in json) {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "1";
|
||||
document.getElementById("publish-result").value = json.Error;
|
||||
} else {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("ha-publishmessage").style.opacity = "1";
|
||||
document.getElementById("publish-result").value = "Erfolg!";
|
||||
window.location.replace("/Admin/Upload/");
|
||||
}
|
||||
})
|
||||
.catch ((e) => {
|
||||
document.getElementById("ha-publishfilelabel").style.pointerEvents = "auto";
|
||||
document.getElementById("ha-lds-ellipsis-publish").style.display = "none";
|
||||
document.getElementById("publish-result").value = "Keine Antwort. Bitte Seite neu laden!";
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
var publishelement = document.getElementById("ha-publishform");
|
||||
var publishbutton = document.getElementById("ha-publishfilelabel");
|
||||
publishbutton.addEventListener("click", () => LOCALPUBLISHSubmit(publishelement));
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -1870,6 +1870,9 @@ body {
|
||||
.ha-adminuploadfields .ha-uploadfield.active {
|
||||
--tw-text-opacity: 1 !important;
|
||||
color: rgb(0 0 0 / var(--tw-text-opacity)) !important;
|
||||
--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||
--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
--tw-brightness: brightness(1.1);
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
@@ -1939,8 +1942,8 @@ body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
@@ -1960,43 +1963,33 @@ body {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
border-radius: 0.25rem;
|
||||
.ha-adminuploadfields .ha-publishbutton {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-shrink: 1;
|
||||
cursor: pointer;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(248 250 252 / var(--tw-bg-opacity));
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-lds-ellipsis {
|
||||
left: 50%;
|
||||
margin-left: -20px;
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishfilelabel {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishfilelabel:hover {
|
||||
.ha-adminuploadfields .ha-publishbutton:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(241 245 249 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishtext {
|
||||
.ha-adminuploadfields .ha-publishbutton .ha-publishtext {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishmessage {
|
||||
.ha-adminuploadfields .ha-publishbutton .ha-publishmessage {
|
||||
border-radius: 0.125rem;
|
||||
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
|
||||
--tw-bg-opacity: 0.3;
|
||||
@@ -2034,6 +2027,41 @@ body {
|
||||
background-color: rgb(248 250 252 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist {
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 4rem;
|
||||
padding-right: 4rem;
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist .ha-publishfilelisttitle {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist td {
|
||||
padding-right: 1.5rem;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist .ha-publishfilelabel {
|
||||
float: right;
|
||||
margin-top: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 2px;
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(29 78 216 / var(--tw-border-opacity));
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist .ha-publishfilelabel:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(147 197 253 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-availablefiles {
|
||||
cursor: pointer;
|
||||
border-width: 1px;
|
||||
@@ -2179,9 +2207,14 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ha-selectfilesform .ha-filelistoutput {
|
||||
margin-top: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.ha-selectfilesform .ha-filelistbutton {
|
||||
float: right;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
|
||||
@@ -679,7 +679,7 @@
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-uploadfield.active {
|
||||
@apply !text-black brightness-110
|
||||
@apply !text-black brightness-110 shadow-inner
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-uploadfield .ha-uploadfieldname {
|
||||
@@ -711,30 +711,22 @@
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-uploadform .ha-uploadfilelabel {
|
||||
@apply inline-block px-2 py-1 pt-2 cursor-pointer w-full h-full hover:bg-slate-100
|
||||
@apply inline-block px-4 py-1 pt-2 cursor-pointer w-full h-full hover:bg-slate-100
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-uploadform .ha-uploadmessage {
|
||||
@apply text-sm bg-slate-700 bg-opacity-30 px-1 rounded-sm
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform {
|
||||
@apply bg-slate-50 rounded shadow grow relative
|
||||
.ha-adminuploadfields .ha-publishbutton {
|
||||
@apply inline-block px-2 py-1 pt-2 cursor-pointer w-full h-full bg-slate-50 shadow shrink hover:bg-slate-100
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-lds-ellipsis {
|
||||
@apply left-1/2 -ml-[20px]
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishfilelabel {
|
||||
@apply inline-block px-2 py-1 pt-2 cursor-pointer w-full h-full hover:bg-slate-100
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishtext {
|
||||
.ha-adminuploadfields .ha-publishbutton .ha-publishtext {
|
||||
@apply text-center
|
||||
}
|
||||
|
||||
.ha-adminuploadfields .ha-publishform .ha-publishmessage {
|
||||
.ha-adminuploadfields .ha-publishbutton .ha-publishmessage {
|
||||
@apply text-sm bg-slate-700 bg-opacity-30 px-1 rounded-sm
|
||||
}
|
||||
|
||||
@@ -750,6 +742,22 @@
|
||||
@apply w-full bg-slate-50 flex flex-col gap-y-2 h-full
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist {
|
||||
@apply px-16 mb-8
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist .ha-publishfilelisttitle {
|
||||
@apply text-xl mb-2
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist td {
|
||||
@apply align-text-top pr-6
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-publishfilelist .ha-publishfilelabel {
|
||||
@apply mt-4 ml-6 rounded-md px-3 border-2 border-blue-700 hover:bg-blue-300 cursor-pointer float-right;
|
||||
}
|
||||
|
||||
.ha-uploadcontainer .ha-availablefiles {
|
||||
@apply px-16 border border-slate-200 hover:border-slate-800 py-2 cursor-pointer
|
||||
}
|
||||
@@ -836,8 +844,12 @@
|
||||
@apply grow text-right
|
||||
}
|
||||
|
||||
.ha-selectfilesform .ha-filelistoutput {
|
||||
@apply mt-4 ml-6
|
||||
}
|
||||
|
||||
.ha-selectfilesform .ha-filelistbutton {
|
||||
@apply mt-2 ml-6 rounded-md px-3 border-2 border-blue-700 hover:bg-blue-300 cursor-pointer float-right;
|
||||
@apply mt-4 ml-6 rounded-md px-3 border-2 border-blue-700 hover:bg-blue-300 cursor-pointer float-right;
|
||||
}
|
||||
|
||||
/* Classes for Letter View */
|
||||
|
||||
@@ -37,7 +37,7 @@ const markactive_exact = function (element) {
|
||||
full_path = location.href.split("#")[0].toLowerCase(); //Ignore hashes
|
||||
|
||||
for (; i < len; i++) {
|
||||
if (full_path == all_links[i].href.toLowerCase()) {
|
||||
if (full_path == all_links[i].href.toLowerCase() || full_path == all_links[i].href.toLowerCase() + "/") {
|
||||
all_links[i].className += " active";
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ window.addEventListener("load", function () {
|
||||
if (document.getElementById("ha-register-nav") != null)
|
||||
markactive_exact(document.getElementById("ha-register-nav"));
|
||||
if (this.document.getElementById("ha-adminuploadfields") != null)
|
||||
markactive_startswith(document.getElementById("ha-adminuploadfields"));
|
||||
markactive_exact(document.getElementById("ha-adminuploadfields"));
|
||||
|
||||
// Letter / Register View: Collapse all unfit boxes + resize observer
|
||||
collapseboxes();
|
||||
|
||||
Reference in New Issue
Block a user