Add file upload on gist creation/edition (#507)
This commit is contained in:
@@ -487,8 +487,14 @@ func (gist *Gist) AddAndCommitFiles(files *[]FileDTO) error {
|
||||
}
|
||||
|
||||
for _, file := range *files {
|
||||
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return err
|
||||
if file.SourcePath != "" { // if it's an uploaded file
|
||||
if err := git.MoveFileToRepository(gist.Uuid, file.Filename, file.SourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else { // else it's a text editor file
|
||||
if err := git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,19 +552,28 @@ func (gist *Gist) UpdatePreviewAndCount(withTimestampUpdate bool) error {
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = ""
|
||||
} else {
|
||||
file, err := gist.File("HEAD", filesStr[0], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fileStr := range filesStr {
|
||||
file, err := gist.File("HEAD", fileStr, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if file == nil {
|
||||
continue
|
||||
}
|
||||
gist.Preview = ""
|
||||
gist.PreviewFilename = file.Filename
|
||||
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
if !file.MimeType.CanBeEdited() {
|
||||
continue
|
||||
}
|
||||
|
||||
gist.PreviewFilename = file.Filename
|
||||
split := strings.Split(file.Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = file.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if withTimestampUpdate {
|
||||
@@ -721,9 +736,10 @@ type VisibilityDTO struct {
|
||||
}
|
||||
|
||||
type FileDTO struct {
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string `validate:"required"`
|
||||
Binary bool
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=255"`
|
||||
Content string
|
||||
Binary bool
|
||||
SourcePath string // Path to uploaded file, used instead of Content when present
|
||||
}
|
||||
|
||||
func (dto *GistDTO) ToGist() *Gist {
|
||||
|
||||
@@ -380,6 +380,17 @@ func SetFileContent(gistTmpId string, filename string, content string) error {
|
||||
return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func MoveFileToRepository(gistTmpId string, filename string, sourcePath string) error {
|
||||
repositoryPath := TmpRepositoryPath(gistTmpId)
|
||||
destPath := filepath.Join(repositoryPath, filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Rename(sourcePath, destPath)
|
||||
}
|
||||
|
||||
func AddAll(gistTmpId string) error {
|
||||
tmpPath := TmpRepositoryPath(gistTmpId)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ gist.file-binary-edit: This file is binary.
|
||||
gist.watch-full-file: View the full file.
|
||||
gist.file-not-valid: This file is not a valid CSV file.
|
||||
gist.no-content: No files found
|
||||
gist.preview-non-available: Preview not available
|
||||
|
||||
gist.new.new_gist: New gist
|
||||
gist.new.title: Title
|
||||
@@ -48,6 +49,8 @@ gist.new.create-private-button: Create private gist
|
||||
gist.new.preview: Preview
|
||||
gist.new.create-a-new-gist: Create a new gist
|
||||
gist.new.topics: Topics (separate with spaces)
|
||||
gist.new.drop-files: Drop files here or click to upload
|
||||
gist.new.any-file-type: Upload any file type
|
||||
|
||||
gist.edit.editing: Editing
|
||||
gist.edit.edit-gist: Edit %s
|
||||
@@ -218,6 +221,8 @@ error.cannot-bind-data: Cannot bind data
|
||||
error.invalid-number: Invalid number
|
||||
error.invalid-character-unescaped: Invalid character unescaped
|
||||
error.not-in-mfa-session: User is not in a MFA session
|
||||
error.no-file-uploaded: No file uploaded
|
||||
error.cannot-open-file: Cannot open uploaded file
|
||||
|
||||
header.menu.all: All
|
||||
header.menu.new: New
|
||||
|
||||
@@ -2,11 +2,15 @@ package gist
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/db"
|
||||
"github.com/thomiceli/opengist/internal/git"
|
||||
"github.com/thomiceli/opengist/internal/i18n"
|
||||
"github.com/thomiceli/opengist/internal/validator"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
@@ -44,10 +48,16 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
|
||||
dto.Files = make([]db.FileDTO, 0)
|
||||
fileCounter := 0
|
||||
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
|
||||
name := ctx.Request().PostForm["name"][i]
|
||||
content := ctx.Request().PostForm["content"][i]
|
||||
|
||||
names := ctx.Request().PostForm["name"]
|
||||
contents := ctx.Request().PostForm["content"]
|
||||
|
||||
// Process files from text editors
|
||||
for i, content := range contents {
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
name := names[i]
|
||||
if name == "" {
|
||||
fileCounter += 1
|
||||
name = "gistfile" + strconv.Itoa(fileCounter) + ".txt"
|
||||
@@ -59,10 +69,57 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: strings.Trim(name, " "),
|
||||
Filename: strings.TrimSpace(name),
|
||||
Content: escapedValue,
|
||||
})
|
||||
}
|
||||
|
||||
// Process uploaded files from UUID arrays
|
||||
fileUUIDs := ctx.Request().PostForm["uploadedfile_uuid"]
|
||||
fileFilenames := ctx.Request().PostForm["uploadedfile_filename"]
|
||||
if len(fileUUIDs) == len(fileFilenames) {
|
||||
for i, fileUUID := range fileUUIDs {
|
||||
filePath := filepath.Join(filepath.Join(config.GetHomeDir(), "uploads"), fileUUID)
|
||||
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: fileFilenames[i],
|
||||
SourcePath: filePath,
|
||||
Content: "", // Empty since we're using SourcePath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Process binary file operations (edit mode)
|
||||
binaryOldNames := ctx.Request().PostForm["binary_old_name"]
|
||||
binaryNewNames := ctx.Request().PostForm["binary_new_name"]
|
||||
if len(binaryOldNames) == len(binaryNewNames) {
|
||||
for i, oldName := range binaryOldNames {
|
||||
newName := binaryNewNames[i]
|
||||
|
||||
if newName == "" { // deletion
|
||||
continue
|
||||
}
|
||||
|
||||
if !isCreate {
|
||||
gistOld := ctx.GetData("gist").(*db.Gist)
|
||||
|
||||
fileContent, _, err := git.GetFileContent(gistOld.User.Username, gistOld.Uuid, "HEAD", oldName, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dto.Files = append(dto.Files, db.FileDTO{
|
||||
Filename: newName,
|
||||
Content: fileContent,
|
||||
Binary: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.SetData("dto", dto)
|
||||
|
||||
err = ctx.Validate(dto)
|
||||
@@ -101,24 +158,13 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
}
|
||||
|
||||
if gist.Title == "" {
|
||||
if ctx.Request().PostForm["name"][0] == "" {
|
||||
if dto.Files[0].Filename == "" {
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
} else {
|
||||
gist.Title = ctx.Request().PostForm["name"][0]
|
||||
gist.Title = dto.Files[0].Filename
|
||||
}
|
||||
}
|
||||
|
||||
if len(dto.Files) > 0 {
|
||||
split := strings.Split(dto.Files[0].Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = dto.Files[0].Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = dto.Files[0].Filename
|
||||
}
|
||||
|
||||
if err = gist.InitRepository(); err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating the repository", err)
|
||||
}
|
||||
@@ -139,6 +185,9 @@ func ProcessCreate(ctx *context.Context) error {
|
||||
|
||||
gist.AddInIndex()
|
||||
gist.UpdateLanguages()
|
||||
if err = gist.UpdatePreviewAndCount(true); err != nil {
|
||||
return ctx.ErrorRes(500, "Error updating preview and count", err)
|
||||
}
|
||||
|
||||
return ctx.RedirectTo("/" + user.Username + "/" + gist.Identifier())
|
||||
}
|
||||
|
||||
77
internal/web/handlers/gist/upload.go
Normal file
77
internal/web/handlers/gist/upload.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package gist
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thomiceli/opengist/internal/config"
|
||||
"github.com/thomiceli/opengist/internal/web/context"
|
||||
)
|
||||
|
||||
func Upload(ctx *context.Context) error {
|
||||
err := ctx.Request().ParseMultipartForm(32 << 20) // 32 MB max
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), err)
|
||||
}
|
||||
|
||||
fileHeader, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.no-file-uploaded"), err)
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.cannot-open-file"), err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileUUID, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error generating UUID", err)
|
||||
}
|
||||
|
||||
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating uploads directory", err)
|
||||
}
|
||||
|
||||
filename := fileUUID.String()
|
||||
filePath := filepath.Join(uploadsDir, filename)
|
||||
|
||||
destFile, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return ctx.ErrorRes(500, "Error creating file", err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, file); err != nil {
|
||||
return ctx.ErrorRes(500, "Error saving file", err)
|
||||
}
|
||||
|
||||
return ctx.JSON(200, map[string]string{
|
||||
"uuid": filename,
|
||||
"filename": fileHeader.Filename,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteUpload(ctx *context.Context) error {
|
||||
uuid := ctx.Param("uuid")
|
||||
if uuid == "" {
|
||||
return ctx.ErrorRes(400, ctx.Tr("error.bad-request"), nil)
|
||||
}
|
||||
|
||||
uploadsDir := filepath.Join(config.GetHomeDir(), "uploads")
|
||||
filePath := filepath.Join(uploadsDir, uuid)
|
||||
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return ctx.ErrorRes(500, "Error deleting file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.JSON(200, map[string]string{
|
||||
"status": "deleted",
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,8 @@ func (s *Server) registerRoutes() {
|
||||
r.GET("/", gist.Create, logged)
|
||||
r.POST("/", gist.ProcessCreate, logged)
|
||||
r.POST("/preview", gist.Preview, logged)
|
||||
r.POST("/upload", gist.Upload, logged)
|
||||
r.DELETE("/upload/:uuid", gist.DeleteUpload, logged)
|
||||
|
||||
r.GET("/healthcheck", health.Healthcheck)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestGists(t *testing.T) {
|
||||
Content: []string{"", "yeah\ncool", "yeah\ncool gist actually"},
|
||||
Topics: "",
|
||||
}
|
||||
err = s.Request("POST", "/", gist2, 400)
|
||||
err = s.Request("POST", "/", gist2, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3 := db.GistDTO{
|
||||
@@ -82,7 +82,7 @@ func TestGists(t *testing.T) {
|
||||
err = s.Request("POST", "/", gist3, 302)
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3db, err := db.GetGistByID("2")
|
||||
gist3db, err := db.GetGistByID("3")
|
||||
require.NoError(t, err)
|
||||
|
||||
gist3files, err := git.GetFilesOfRepository(gist3db.User.Username, gist3db.Uuid, "HEAD")
|
||||
|
||||
239
public/editor.ts
239
public/editor.ts
@@ -117,7 +117,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtns !== null) {
|
||||
deleteBtns.onclick = () => {
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
// For both text and binary files, just remove from DOM
|
||||
if (!dom.hasAttribute('data-binary-original-name')) {
|
||||
// Only remove from editors array for text files
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
}
|
||||
dom.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
@@ -200,20 +204,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (formFileContent !== null) {
|
||||
let currEditor = newEditor(el, el.querySelector<HTMLInputElement>(".form-filecontent")!.value);
|
||||
editorsjs.push(currEditor);
|
||||
} else if (el.hasAttribute('data-binary-original-name')) {
|
||||
// For binary files, just set up the delete button
|
||||
let deleteBtn = el.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.onclick = () => {
|
||||
el.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkForFirstDeleteButton();
|
||||
|
||||
document.getElementById("add-file")!.onclick = () => {
|
||||
let newEditorDom = firstEditordom.cloneNode(true) as HTMLElement;
|
||||
|
||||
// reset the filename of the new cloned element
|
||||
newEditorDom.querySelector<HTMLInputElement>('input[name="name"]')!.value = "";
|
||||
|
||||
// removing the previous codemirror editor
|
||||
let newEditorDomCM = newEditorDom.querySelector(".cm-editor");
|
||||
newEditorDomCM!.remove();
|
||||
const template = document.getElementById("editor-template")!;
|
||||
const newEditorDom = template.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom));
|
||||
@@ -223,9 +230,56 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
|
||||
let j = 0;
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((e) => {
|
||||
e.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((el) => {
|
||||
if (j < editorsjs.length) {
|
||||
el.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.remove();
|
||||
}
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>("form#create")!;
|
||||
|
||||
uploadedFileUUIDs.forEach((fileData) => {
|
||||
const uuidInput = document.createElement('input');
|
||||
uuidInput.type = 'hidden';
|
||||
uuidInput.name = 'uploadedfile_uuid';
|
||||
uuidInput.value = fileData.uuid;
|
||||
form.appendChild(uuidInput);
|
||||
|
||||
const filenameInput = document.createElement('input');
|
||||
filenameInput.type = 'hidden';
|
||||
filenameInput.name = 'uploadedfile_filename';
|
||||
filenameInput.value = fileData.filename;
|
||||
form.appendChild(filenameInput);
|
||||
});
|
||||
|
||||
const binaryFiles = document.querySelectorAll('[data-binary-original-name]');
|
||||
binaryFiles.forEach((fileDiv) => {
|
||||
const originalName = fileDiv.getAttribute('data-binary-original-name');
|
||||
const fileNameInput = fileDiv.querySelector('.form-filename') as HTMLInputElement;
|
||||
|
||||
if (fileNameInput) {
|
||||
fileNameInput.removeAttribute('name');
|
||||
}
|
||||
|
||||
const oldNameInput = document.createElement('input');
|
||||
oldNameInput.type = 'hidden';
|
||||
oldNameInput.name = 'binary_old_name';
|
||||
oldNameInput.value = originalName || '';
|
||||
form.appendChild(oldNameInput);
|
||||
|
||||
const newNameInput = document.createElement('input');
|
||||
newNameInput.type = 'hidden';
|
||||
newNameInput.name = 'binary_new_name';
|
||||
newNameInput.value = fileNameInput?.value || '';
|
||||
form.appendChild(newNameInput);
|
||||
});
|
||||
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
|
||||
document.getElementById('gist-metadata-btn')!.onclick = (el) => {
|
||||
@@ -242,16 +296,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
function checkForFirstDeleteButton() {
|
||||
let deleteBtn = editorsParentdom.querySelector<HTMLButtonElement>("button.delete-file")!;
|
||||
if (editorsjs.length === 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-md");
|
||||
}
|
||||
// Count total files (both text and binary)
|
||||
const totalFiles = editorsParentdom.querySelectorAll('.editor').length;
|
||||
|
||||
// Hide/show all delete buttons based on total file count
|
||||
const deleteButtons = editorsParentdom.querySelectorAll<HTMLButtonElement>("button.delete-file");
|
||||
deleteButtons.forEach(deleteBtn => {
|
||||
if (totalFiles <= 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-md");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteButton(editorDom: HTMLElement) {
|
||||
@@ -262,7 +322,140 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
checkForFirstDeleteButton();
|
||||
}
|
||||
|
||||
document.onsubmit = () => {
|
||||
window.onbeforeunload = null;
|
||||
// File upload functionality
|
||||
let uploadedFileUUIDs: {uuid: string, filename: string}[] = [];
|
||||
const fileUploadInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
const uploadedFilesContainer = document.getElementById("uploaded-files")!;
|
||||
const fileUploadZone = document.getElementById("file-upload-zone")!.querySelector('.border-dashed') as HTMLElement;
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = (files: FileList) => {
|
||||
Array.from(files).forEach(file => {
|
||||
if (!uploadedFileUUIDs.find(f => f.filename === file.name)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Upload file to server
|
||||
const uploadFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
uploadedFileUUIDs.push({uuid: result.uuid, filename: result.filename});
|
||||
addFileToUI(result.filename, result.uuid, file.size);
|
||||
} else {
|
||||
console.error('Upload failed:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add file to UI
|
||||
const addFileToUI = (filename: string, uuid: string, fileSize: number) => {
|
||||
const fileElement = document.createElement('div');
|
||||
fileElement.className = 'flex items-stretch bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden';
|
||||
fileElement.dataset.uuid = uuid;
|
||||
|
||||
fileElement.innerHTML = `
|
||||
<div class="flex items-center space-x-3 px-3 py-1 flex-1">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">${filename}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">${formatFileSize(fileSize)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-file flex items-center justify-center px-4 border-l-1 dark:border-l-1 text-rose-600 dark:text-rose-400 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Remove file handler
|
||||
fileElement.querySelector('.remove-file')!.addEventListener('click', async () => {
|
||||
// Remove from server
|
||||
try {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
await fetch(`${baseUrl}/upload/${uuid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
|
||||
// Remove from UI and local array
|
||||
uploadedFileUUIDs = uploadedFileUUIDs.filter(f => f.uuid !== uuid);
|
||||
fileElement.remove();
|
||||
});
|
||||
|
||||
uploadedFilesContainer.appendChild(fileElement);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
fileUploadInput.addEventListener('change', (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
// Clear the input value immediately so it doesn't get submitted with the form
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop handlers
|
||||
fileUploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.add('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
23
templates/pages/create.html
vendored
23
templates/pages/create.html
vendored
@@ -41,6 +41,24 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div id="file-upload-zone" class="space-y-4">
|
||||
<label for="file-upload" class="cursor-pointer block">
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center hover:border-primary-400 dark:hover:border-primary-500 transition-colors">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ .locale.Tr "gist.new.drop-files" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ .locale.Tr "gist.new.any-file-type" }}</p>
|
||||
</div>
|
||||
<input id="file-upload" name="file-upload" type="file" class="sr-only" multiple accept="*/*">
|
||||
</label>
|
||||
<div id="uploaded-files" class="space-y-2"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">{{ .locale.Tr "gist.new.add-file" }}</button>
|
||||
|
||||
@@ -65,6 +83,11 @@
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
<!-- Hidden template for new text editors -->
|
||||
<div id="editor-template" class="hidden">
|
||||
{{ template "_editor" dict "Filename" "" "Content" "" "Binary" false "locale" .locale }}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
23
templates/pages/edit.html
vendored
23
templates/pages/edit.html
vendored
@@ -72,7 +72,23 @@
|
||||
{{ template "_editor" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div id="file-upload-zone" class="space-y-4">
|
||||
<label for="file-upload" class="cursor-pointer block">
|
||||
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 text-center hover:border-primary-400 dark:hover:border-primary-500 transition-colors">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{{ .locale.Tr "gist.new.drop-files" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ .locale.Tr "gist.new.any-file-type" }}</p>
|
||||
</div>
|
||||
<input id="file-upload" name="file-upload" type="file" class="sr-only" multiple accept="*/*">
|
||||
</label>
|
||||
<div id="uploaded-files" class="space-y-2"></div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button type="button" id="add-file" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-gray-700 dark:text-white bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">{{ .locale.Tr "gist.new.add-file" }}</button>
|
||||
<a href="{{ $.c.ExternalUrl }}/{{ .gist.User.Username }}/{{ .gist.Identifier }}" class="ml-auto inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 text-rose-600 dark:text-rose-400 hover:text-rose-700">{{ .locale.Tr "gist.edit.cancel" }}</a>
|
||||
@@ -81,6 +97,11 @@
|
||||
{{ .csrfHtml }}
|
||||
</form>
|
||||
|
||||
<!-- Hidden template for new text editors -->
|
||||
<div id="editor-template" class="hidden">
|
||||
{{ template "_editor" dict "Filename" "" "Content" "" "Binary" false "locale" .locale }}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
2
templates/partials/_editor.html
vendored
2
templates/partials/_editor.html
vendored
@@ -1,5 +1,5 @@
|
||||
{{ define "_editor" }}
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 editor">
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 editor"{{ if .Binary }} data-binary-original-name="{{ .Filename }}"{{ end }}>
|
||||
<div class="border-b-1 border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 my-auto flex">
|
||||
<p class="mx-2 my-2 inline-flex">
|
||||
<input type="text" name="name" value="{{ .Filename }}" placeholder="{{ $.locale.Tr "gist.new.filename-with-extension" }}" style="line-height: 0.05em" class="form-filename bg-white dark:bg-gray-900 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-200 dark:border-gray-700 rounded-l-md gist-title" autocomplete="off" data-lpignore data-bwignore data-1p-ignore>
|
||||
|
||||
38
templates/partials/_gist_preview.html
vendored
38
templates/partials/_gist_preview.html
vendored
@@ -60,24 +60,28 @@
|
||||
<div class="rounded-md border border-1 border-gray-200 dark:border-gray-700 overflow-auto hover:border-primary-600">
|
||||
<div class="code overflow-auto">
|
||||
{{ if .gist.PreviewFilename }}
|
||||
{{ if isMarkdown .gist.PreviewFilename }}
|
||||
<div class="chroma preview markdown markdown-body p-8">{{ .gist.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<table class="chroma table-code w-full {{ if .currentStyle }}{{ if .currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" data-filename="{{ .gist.PreviewFilename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := .gist.Lines }}
|
||||
{{ if .gist.Preview }}
|
||||
{{ if isMarkdown .gist.PreviewFilename }}
|
||||
<div class="chroma preview markdown markdown-body p-8">{{ .gist.HTML | safe }}</div>
|
||||
{{ else }}
|
||||
<table class="chroma table-code w-full {{ if .currentStyle }}{{ if .currentStyle.SoftWrap }}whitespace-pre-wrap{{ else }}whitespace-pre{{ end }}{{ else }}whitespace-pre{{ end }}" data-filename="{{ .gist.PreviewFilename }}" style="font-size: 0.8em; border-spacing: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
{{ $ii := "1" }}
|
||||
{{ $i := toInt $ii }}
|
||||
{{ range $line := .gist.Lines }}
|
||||
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">{{$i}}</td>
|
||||
<td class="line-code break-all">{{ $line | safe }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td class="select-none line-num px-4">{{$i}}</td>
|
||||
<td class="line-code break-all">{{ $line | safe }}</td>
|
||||
</tr>
|
||||
{{ $i = inc $i }}
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="pl-4 py-0.5 text-xs"><p>{{ .locale.Tr "gist.preview-non-available" }}</p></div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="pl-4 py-0.5 text-xs"><p>{{ .locale.Tr "gist.no-content" }}</p></div>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user