mirror of
				https://github.com/owncast/owncast.git
				synced 2025-11-01 02:44:31 +08:00 
			
		
		
		
	Feature: emoji editor (#2411)
* Custom emoji editor: implement backend This reuses the logo upload code * Implement emoji edit admin interface Again reuse base64 logic from the logo upload * Allow toggling between uploaded and default emojis * Add route that always serves uploaded emojis This is needed for the admin emoji interface, as otherwise the emojis will 404 if custom emojis are disabled * Fix linter warnings * Remove custom/uploaded emoji logic * Reset timer after emoji deletion * Setup: copy built-in emojis to emoji directory
This commit is contained in:
		| @ -9,8 +9,8 @@ const ( | |||||||
| 	FfmpegSuggestedVersion = "v4.1.5" // Requires the v | 	FfmpegSuggestedVersion = "v4.1.5" // Requires the v | ||||||
| 	// DataDirectory is the directory we save data to. | 	// DataDirectory is the directory we save data to. | ||||||
| 	DataDirectory = "data" | 	DataDirectory = "data" | ||||||
| 	// EmojiDir is relative to the static directory. | 	// EmojiDir defines the URL route prefix for emoji requests. | ||||||
| 	EmojiDir = "/img/emoji" | 	EmojiDir = "/img/emoji/" | ||||||
| 	// MaxUserColor is the largest color value available to assign to users. | 	// MaxUserColor is the largest color value available to assign to users. | ||||||
| 	// They start at 0 and can be treated as IDs more than colors themselves. | 	// They start at 0 and can be treated as IDs more than colors themselves. | ||||||
| 	MaxUserColor = 7 | 	MaxUserColor = 7 | ||||||
| @ -25,6 +25,6 @@ var ( | |||||||
| 	// HLSStoragePath is the directory HLS video is written to. | 	// HLSStoragePath is the directory HLS video is written to. | ||||||
| 	HLSStoragePath = filepath.Join(DataDirectory, "hls") | 	HLSStoragePath = filepath.Join(DataDirectory, "hls") | ||||||
|  |  | ||||||
| 	// CustomEmojiPath is the optional emoji override directory. | 	// CustomEmojiPath is the emoji directory. | ||||||
| 	CustomEmojiPath = filepath.Join(DataDirectory, "emoji") | 	CustomEmojiPath = filepath.Join(DataDirectory, "emoji") | ||||||
| ) | ) | ||||||
|  | |||||||
| @ -1,7 +1,6 @@ | |||||||
| package admin | package admin | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/base64" |  | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net" | 	"net" | ||||||
| @ -228,39 +227,12 @@ func SetLogo(w http.ResponseWriter, r *http.Request) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	s := strings.SplitN(configValue.Value.(string), ",", 2) | 	bytes, extension, err := utils.DecodeBase64Image(configValue.Value.(string)) | ||||||
| 	if len(s) < 2 { |  | ||||||
| 		controllers.WriteSimpleResponse(w, false, "Error splitting base64 image data.") |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	bytes, err := base64.StdEncoding.DecodeString(s[1]) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	splitHeader := strings.Split(s[0], ":") |  | ||||||
| 	if len(splitHeader) < 2 { |  | ||||||
| 		controllers.WriteSimpleResponse(w, false, "Error splitting base64 image header.") |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	contentType := strings.Split(splitHeader[1], ";")[0] |  | ||||||
| 	extension := "" |  | ||||||
| 	if contentType == "image/svg+xml" { |  | ||||||
| 		extension = ".svg" |  | ||||||
| 	} else if contentType == "image/gif" { |  | ||||||
| 		extension = ".gif" |  | ||||||
| 	} else if contentType == "image/png" { |  | ||||||
| 		extension = ".png" |  | ||||||
| 	} else if contentType == "image/jpeg" { |  | ||||||
| 		extension = ".jpeg" |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	if extension == "" { |  | ||||||
| 		controllers.WriteSimpleResponse(w, false, "Missing or invalid contentType in base64 image.") |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	imgPath := filepath.Join("data", "logo"+extension) | 	imgPath := filepath.Join("data", "logo"+extension) | ||||||
| 	if err := os.WriteFile(imgPath, bytes, 0o600); err != nil { | 	if err := os.WriteFile(imgPath, bytes, 0o600); err != nil { | ||||||
| 		controllers.WriteSimpleResponse(w, false, err.Error()) | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | |||||||
							
								
								
									
										92
									
								
								controllers/admin/emoji.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								controllers/admin/emoji.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | |||||||
|  | package admin | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  |  | ||||||
|  | 	"github.com/owncast/owncast/config" | ||||||
|  | 	"github.com/owncast/owncast/controllers" | ||||||
|  | 	"github.com/owncast/owncast/utils" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // UploadCustomEmoji allows POSTing a new custom emoji to the server. | ||||||
|  | func UploadCustomEmoji(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	if !requirePOST(w, r) { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	type postEmoji struct { | ||||||
|  | 		Name string `json:"name"` | ||||||
|  | 		Data string `json:"data"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	emoji := new(postEmoji) | ||||||
|  |  | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	bytes, _, err := utils.DecodeBase64Image(emoji.Data) | ||||||
|  | 	if err != nil { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Prevent path traversal attacks | ||||||
|  | 	var emojiFileName = filepath.Base(emoji.Name) | ||||||
|  | 	var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) | ||||||
|  |  | ||||||
|  | 	err = os.MkdirAll(config.CustomEmojiPath, 0700) | ||||||
|  | 	if err != nil { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if utils.DoesFileExists(targetPath) { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, fmt.Sprintf("An emoji with the name %q already exists", emojiFileName)) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err = os.WriteFile(targetPath, bytes, 0o600); err != nil { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been uploaded", emojiFileName)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // DeleteCustomEmoji deletes a custom emoji. | ||||||
|  | func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	if !requirePOST(w, r) { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	type deleteEmoji struct { | ||||||
|  | 		Name string `json:"name"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	emoji := new(deleteEmoji) | ||||||
|  |  | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(emoji); err != nil { | ||||||
|  | 		controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var emojiFileName = filepath.Base(emoji.Name) | ||||||
|  | 	var targetPath = filepath.Join(config.CustomEmojiPath, emojiFileName) | ||||||
|  |  | ||||||
|  | 	if err := os.Remove(targetPath); err != nil { | ||||||
|  | 		if os.IsNotExist(err) { | ||||||
|  | 			controllers.WriteSimpleResponse(w, false, fmt.Sprintf("Emoji %q doesn't exist", emojiFileName)) | ||||||
|  | 		} else { | ||||||
|  | 			controllers.WriteSimpleResponse(w, false, err.Error()) | ||||||
|  | 		} | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been deleted", emojiFileName)) | ||||||
|  | } | ||||||
| @ -2,50 +2,17 @@ package controllers | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"io/fs" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
| 	"path/filepath" |  | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/owncast/owncast/config" | 	"github.com/owncast/owncast/config" | ||||||
| 	"github.com/owncast/owncast/models" | 	"github.com/owncast/owncast/core/data" | ||||||
| 	"github.com/owncast/owncast/static" |  | ||||||
| 	"github.com/owncast/owncast/utils" |  | ||||||
| 	log "github.com/sirupsen/logrus" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var useCustomEmojiDirectory = utils.DoesFileExists(config.CustomEmojiPath) | // GetCustomEmojiList returns a list of emoji via the API. | ||||||
|  |  | ||||||
| // getCustomEmojiList returns a list of custom emoji either from the cache or from the emoji directory. |  | ||||||
| func getCustomEmojiList() []models.CustomEmoji { |  | ||||||
| 	var emojiFS fs.FS |  | ||||||
| 	if useCustomEmojiDirectory { |  | ||||||
| 		emojiFS = os.DirFS(config.CustomEmojiPath) |  | ||||||
| 	} else { |  | ||||||
| 		emojiFS = static.GetEmoji() |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	emojiResponse := make([]models.CustomEmoji, 0) |  | ||||||
|  |  | ||||||
| 	files, err := fs.Glob(emojiFS, "*") |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Errorln(err) |  | ||||||
| 		return emojiResponse |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	for _, name := range files { |  | ||||||
| 		emojiPath := filepath.Join(config.EmojiDir, name) |  | ||||||
| 		singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} |  | ||||||
| 		emojiResponse = append(emojiResponse, singleEmoji) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return emojiResponse |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetCustomEmojiList returns a list of custom emoji via the API. |  | ||||||
| func GetCustomEmojiList(w http.ResponseWriter, r *http.Request) { | func GetCustomEmojiList(w http.ResponseWriter, r *http.Request) { | ||||||
| 	emojiList := getCustomEmojiList() | 	emojiList := data.GetEmojiList() | ||||||
|  |  | ||||||
| 	if err := json.NewEncoder(w).Encode(emojiList); err != nil { | 	if err := json.NewEncoder(w).Encode(emojiList); err != nil { | ||||||
| 		InternalErrorHandler(w, err) | 		InternalErrorHandler(w, err) | ||||||
| @ -57,13 +24,6 @@ func GetCustomEmojiImage(w http.ResponseWriter, r *http.Request) { | |||||||
| 	path := strings.TrimPrefix(r.URL.Path, "/img/emoji/") | 	path := strings.TrimPrefix(r.URL.Path, "/img/emoji/") | ||||||
| 	r.URL.Path = path | 	r.URL.Path = path | ||||||
|  |  | ||||||
| 	var emojiStaticServer http.Handler | 	emojiFS := os.DirFS(config.CustomEmojiPath) | ||||||
| 	if useCustomEmojiDirectory { | 	http.FileServer(http.FS(emojiFS)).ServeHTTP(w, r) | ||||||
| 		emojiFS := os.DirFS(config.CustomEmojiPath) |  | ||||||
| 		emojiStaticServer = http.FileServer(http.FS(emojiFS)) |  | ||||||
| 	} else { |  | ||||||
| 		emojiStaticServer = http.FileServer(http.FS(static.GetEmoji())) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	emojiStaticServer.ServeHTTP(w, r) |  | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										84
									
								
								core/data/emoji.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								core/data/emoji.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | |||||||
|  | package data | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"io/fs" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  |  | ||||||
|  | 	"github.com/owncast/owncast/config" | ||||||
|  | 	"github.com/owncast/owncast/models" | ||||||
|  | 	"github.com/owncast/owncast/static" | ||||||
|  | 	"github.com/owncast/owncast/utils" | ||||||
|  | 	log "github.com/sirupsen/logrus" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // GetEmojiList returns a list of custom emoji from the emoji directory. | ||||||
|  | func GetEmojiList() []models.CustomEmoji { | ||||||
|  | 	var emojiFS = os.DirFS(config.CustomEmojiPath) | ||||||
|  |  | ||||||
|  | 	emojiResponse := make([]models.CustomEmoji, 0) | ||||||
|  |  | ||||||
|  | 	files, err := fs.Glob(emojiFS, "*") | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Errorln(err) | ||||||
|  | 		return emojiResponse | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, name := range files { | ||||||
|  | 		emojiPath := filepath.Join(config.EmojiDir, name) | ||||||
|  | 		singleEmoji := models.CustomEmoji{Name: name, URL: emojiPath} | ||||||
|  | 		emojiResponse = append(emojiResponse, singleEmoji) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return emojiResponse | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SetupEmojiDirectory sets up the custom emoji directory by copying all built-in | ||||||
|  | // emojis if the directory does not yet exist. | ||||||
|  | func SetupEmojiDirectory() (err error) { | ||||||
|  | 	if utils.DoesFileExists(config.CustomEmojiPath) { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err = os.MkdirAll(config.CustomEmojiPath, 0o750); err != nil { | ||||||
|  | 		return fmt.Errorf("unable to create custom emoji directory: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	staticFS := static.GetEmoji() | ||||||
|  | 	files, err := fs.Glob(staticFS, "*") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("unable to read built-in emoji files: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Now copy all built-in emojis to the custom emoji directory | ||||||
|  | 	for _, name := range files { | ||||||
|  | 		emojiPath := filepath.Join(config.CustomEmojiPath, filepath.Base(name)) | ||||||
|  |  | ||||||
|  | 		// nolint:gosec | ||||||
|  | 		diskFile, err := os.Create(emojiPath) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("unable to create custom emoji file on disk: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		memFile, err := staticFS.Open(name) | ||||||
|  | 		if err != nil { | ||||||
|  | 			_ = diskFile.Close() | ||||||
|  | 			return fmt.Errorf("unable to open built-in emoji file: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if _, err = io.Copy(diskFile, memFile); err != nil { | ||||||
|  | 			_ = diskFile.Close() | ||||||
|  | 			_ = os.Remove(emojiPath) | ||||||
|  | 			return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if err = diskFile.Close(); err != nil { | ||||||
|  | 			_ = os.Remove(emojiPath) | ||||||
|  | 			return fmt.Errorf("unable to close custom emoji file on disk: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								main.go
									
									
									
									
									
								
							| @ -47,6 +47,10 @@ func main() { | |||||||
| 			log.Fatalln("Cannot create data directory", err) | 			log.Fatalln("Cannot create data directory", err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 	// Set up emoji directory | ||||||
|  | 	if err := data.SetupEmojiDirectory(); err != nil { | ||||||
|  | 		log.Fatalln("Cannot set up emoji directory", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	// Recreate the temp dir | 	// Recreate the temp dir | ||||||
| 	if utils.DoesFileExists(config.TempDir) { | 	if utils.DoesFileExists(config.TempDir) { | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ func Start() error { | |||||||
| 	http.HandleFunc("/logo", controllers.GetLogo) | 	http.HandleFunc("/logo", controllers.GetLogo) | ||||||
|  |  | ||||||
| 	// Return a single emoji image. | 	// Return a single emoji image. | ||||||
| 	http.HandleFunc("/img/emoji/", controllers.GetCustomEmojiImage) | 	http.HandleFunc(config.EmojiDir, controllers.GetCustomEmojiImage) | ||||||
|  |  | ||||||
| 	// return the logo | 	// return the logo | ||||||
|  |  | ||||||
| @ -156,6 +156,12 @@ func Start() error { | |||||||
| 	// Set the following state of a follower or follow request. | 	// Set the following state of a follower or follow request. | ||||||
| 	http.HandleFunc("/api/admin/followers/approve", middleware.RequireAdminAuth(admin.ApproveFollower)) | 	http.HandleFunc("/api/admin/followers/approve", middleware.RequireAdminAuth(admin.ApproveFollower)) | ||||||
|  |  | ||||||
|  | 	// Upload custom emoji | ||||||
|  | 	http.HandleFunc("/api/admin/emoji/upload", middleware.RequireAdminAuth(admin.UploadCustomEmoji)) | ||||||
|  |  | ||||||
|  | 	// Delete custom emoji | ||||||
|  | 	http.HandleFunc("/api/admin/emoji/delete", middleware.RequireAdminAuth(admin.DeleteCustomEmoji)) | ||||||
|  |  | ||||||
| 	// Update config values | 	// Update config values | ||||||
|  |  | ||||||
| 	// Change the current streaming key in memory | 	// Change the current streaming key in memory | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ package utils | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
|  | 	"encoding/base64" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"io" | 	"io" | ||||||
| @ -364,3 +365,42 @@ func ShuffleStringSlice(s []string) []string { | |||||||
| func IntPercentage(x, total int) int { | func IntPercentage(x, total int) int { | ||||||
| 	return int(float64(x) / float64(total) * 100) | 	return int(float64(x) / float64(total) * 100) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type. | ||||||
|  | func DecodeBase64Image(url string) (bytes []byte, extension string, err error) { | ||||||
|  | 	s := strings.SplitN(url, ",", 2) | ||||||
|  | 	if len(s) < 2 { | ||||||
|  | 		err = errors.New("error splitting base64 image data") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	bytes, err = base64.StdEncoding.DecodeString(s[1]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	splitHeader := strings.Split(s[0], ":") | ||||||
|  | 	if len(splitHeader) < 2 { | ||||||
|  | 		err = errors.New("error splitting base64 image header") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	contentType := strings.Split(splitHeader[1], ";")[0] | ||||||
|  |  | ||||||
|  | 	if contentType == "image/svg+xml" { | ||||||
|  | 		extension = ".svg" | ||||||
|  | 	} else if contentType == "image/gif" { | ||||||
|  | 		extension = ".gif" | ||||||
|  | 	} else if contentType == "image/png" { | ||||||
|  | 		extension = ".png" | ||||||
|  | 	} else if contentType == "image/jpeg" { | ||||||
|  | 		extension = ".jpeg" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if extension == "" { | ||||||
|  | 		err = errors.New("missing or invalid contentType in base64 image") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return bytes, extension, nil | ||||||
|  | } | ||||||
|  | |||||||
| @ -143,6 +143,10 @@ export const MainLayout: FC<MainLayoutProps> = ({ children }) => { | |||||||
|       label: <Link href="/admin/chat/users">Users</Link>, |       label: <Link href="/admin/chat/users">Users</Link>, | ||||||
|       key: 'chat-users', |       key: 'chat-users', | ||||||
|     }, |     }, | ||||||
|  |     { | ||||||
|  |       label: <Link href="/admin/chat/emojis">Emojis</Link>, | ||||||
|  |       key: 'emojis', | ||||||
|  |     }, | ||||||
|   ]; |   ]; | ||||||
|  |  | ||||||
|   const utilitiesMenu = [ |   const utilitiesMenu = [ | ||||||
|  | |||||||
| @ -18,13 +18,7 @@ import { | |||||||
| } from '../../utils/input-statuses'; | } from '../../utils/input-statuses'; | ||||||
| import { NEXT_PUBLIC_API_HOST } from '../../utils/apis'; | import { NEXT_PUBLIC_API_HOST } from '../../utils/apis'; | ||||||
|  |  | ||||||
| const ACCEPTED_FILE_TYPES = ['image/png', 'image/jpeg', 'image/gif']; | import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../utils/images'; | ||||||
|  |  | ||||||
| function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) { |  | ||||||
|   const reader = new FileReader(); |  | ||||||
|   reader.addEventListener('load', () => callback(reader.result)); |  | ||||||
|   reader.readAsDataURL(img); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const EditLogo: FC = () => { | export const EditLogo: FC = () => { | ||||||
|   const [logoUrl, setlogoUrl] = useState(null); |   const [logoUrl, setlogoUrl] = useState(null); | ||||||
| @ -53,7 +47,7 @@ export const EditLogo: FC = () => { | |||||||
|  |  | ||||||
|     // eslint-disable-next-line consistent-return |     // eslint-disable-next-line consistent-return | ||||||
|     return new Promise<void>((res, rej) => { |     return new Promise<void>((res, rej) => { | ||||||
|       if (!ACCEPTED_FILE_TYPES.includes(file.type)) { |       if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { | ||||||
|         const msg = `File type is not supported: ${file.type}`; |         const msg = `File type is not supported: ${file.type}`; | ||||||
|         setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`)); |         setSubmitStatus(createInputStatus(STATUS_ERROR, `There was an error: ${msg}`)); | ||||||
|         resetTimer = setTimeout(resetStates, RESET_TIMEOUT); |         resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||||
| @ -108,7 +102,7 @@ export const EditLogo: FC = () => { | |||||||
|             listType="picture" |             listType="picture" | ||||||
|             className="avatar-uploader" |             className="avatar-uploader" | ||||||
|             showUploadList={false} |             showUploadList={false} | ||||||
|             accept={ACCEPTED_FILE_TYPES.join(',')} |             accept={ACCEPTED_IMAGE_TYPES.join(',')} | ||||||
|             beforeUpload={beforeUpload} |             beforeUpload={beforeUpload} | ||||||
|             customRequest={handleLogoUpdate} |             customRequest={handleLogoUpdate} | ||||||
|             disabled={loading} |             disabled={loading} | ||||||
|  | |||||||
							
								
								
									
										183
									
								
								web/pages/admin/chat/emojis.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								web/pages/admin/chat/emojis.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,183 @@ | |||||||
|  | import { DeleteOutlined } from '@ant-design/icons'; | ||||||
|  | import { Button, Space, Table, Typography, Upload } from 'antd'; | ||||||
|  | import { RcFile } from 'antd/lib/upload'; | ||||||
|  | import React, { useEffect, useState } from 'react'; | ||||||
|  | import FormStatusIndicator from '../../../components/config/FormStatusIndicator'; | ||||||
|  |  | ||||||
|  | import { DELETE_EMOJI, fetchData, UPLOAD_EMOJI } from '../../../utils/apis'; | ||||||
|  |  | ||||||
|  | import { ACCEPTED_IMAGE_TYPES, getBase64 } from '../../../utils/images'; | ||||||
|  | import { | ||||||
|  |   createInputStatus, | ||||||
|  |   STATUS_ERROR, | ||||||
|  |   STATUS_PROCESSING, | ||||||
|  |   STATUS_SUCCESS, | ||||||
|  | } from '../../../utils/input-statuses'; | ||||||
|  | import { RESET_TIMEOUT } from '../../../utils/config-constants'; | ||||||
|  | import { URL_CUSTOM_EMOJIS } from '../../../utils/constants'; | ||||||
|  |  | ||||||
|  | type CustomEmoji = { | ||||||
|  |   name: string; | ||||||
|  |   url: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const { Title, Paragraph } = Typography; | ||||||
|  |  | ||||||
|  | const Emoji = () => { | ||||||
|  |   const [emojis, setEmojis] = useState<CustomEmoji[]>([]); | ||||||
|  |  | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [submitStatus, setSubmitStatus] = useState(null); | ||||||
|  |   const [uploadFile, setUploadFile] = useState<RcFile>(null); | ||||||
|  |  | ||||||
|  |   let resetTimer = null; | ||||||
|  |   const resetStates = () => { | ||||||
|  |     setSubmitStatus(null); | ||||||
|  |     clearTimeout(resetTimer); | ||||||
|  |     resetTimer = null; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   async function getEmojis() { | ||||||
|  |     setLoading(true); | ||||||
|  |     try { | ||||||
|  |       const response = await fetchData(URL_CUSTOM_EMOJIS); | ||||||
|  |       setEmojis(response); | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error('error fetching emojis', error); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |   useEffect(() => { | ||||||
|  |     getEmojis(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   async function handleDelete(name: string) { | ||||||
|  |     setLoading(true); | ||||||
|  |  | ||||||
|  |     setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Deleting emoji...')); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       const response = await fetchData(DELETE_EMOJI, { | ||||||
|  |         method: 'POST', | ||||||
|  |         data: { name }, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (response instanceof Error) { | ||||||
|  |         throw response; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji deleted')); | ||||||
|  |       resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||||
|  |     } catch (error) { | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); | ||||||
|  |       setLoading(false); | ||||||
|  |       resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     getEmojis(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function handleEmojiUpload() { | ||||||
|  |     setLoading(true); | ||||||
|  |     try { | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Converting emoji...')); | ||||||
|  |  | ||||||
|  |       // eslint-disable-next-line consistent-return | ||||||
|  |       const emojiData = await new Promise<CustomEmoji>((res, rej) => { | ||||||
|  |         if (!ACCEPTED_IMAGE_TYPES.includes(uploadFile.type)) { | ||||||
|  |           const msg = `File type is not supported: ${uploadFile.type}`; | ||||||
|  |           // eslint-disable-next-line no-promise-executor-return | ||||||
|  |           return rej(msg); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         getBase64(uploadFile, (url: string) => | ||||||
|  |           res({ | ||||||
|  |             name: uploadFile.name, | ||||||
|  |             url, | ||||||
|  |           }), | ||||||
|  |         ); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_PROCESSING, 'Uploading emoji...')); | ||||||
|  |  | ||||||
|  |       const response = await fetchData(UPLOAD_EMOJI, { | ||||||
|  |         method: 'POST', | ||||||
|  |         data: { | ||||||
|  |           name: emojiData.name, | ||||||
|  |           data: emojiData.url, | ||||||
|  |         }, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       if (response instanceof Error) { | ||||||
|  |         throw response; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Emoji uploaded successfully!')); | ||||||
|  |       getEmojis(); | ||||||
|  |     } catch (error) { | ||||||
|  |       setSubmitStatus(createInputStatus(STATUS_ERROR, `${error}`)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     resetTimer = setTimeout(resetStates, RESET_TIMEOUT); | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const columns = [ | ||||||
|  |     { | ||||||
|  |       title: '', | ||||||
|  |       key: 'delete', | ||||||
|  |       render: (text, record) => ( | ||||||
|  |         <Space size="middle"> | ||||||
|  |           <Button onClick={() => handleDelete(record.name)} icon={<DeleteOutlined />} /> | ||||||
|  |         </Space> | ||||||
|  |       ), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       title: 'Name', | ||||||
|  |       key: 'name', | ||||||
|  |       dataIndex: 'name', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       title: 'Emoji', | ||||||
|  |       key: 'url', | ||||||
|  |       render: (text, record) => ( | ||||||
|  |         <img src={record.url} alt={record.name} style={{ maxWidth: '2vw' }} /> | ||||||
|  |       ), | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div> | ||||||
|  |       <Title>Emojis</Title> | ||||||
|  |       <Paragraph> | ||||||
|  |         Here you can upload new custom emojis for usage in the chat. When uploading a new emoji, the | ||||||
|  |         filename will be used as emoji name. | ||||||
|  |       </Paragraph> | ||||||
|  |  | ||||||
|  |       <Table | ||||||
|  |         rowKey={record => record.url} | ||||||
|  |         dataSource={emojis} | ||||||
|  |         columns={columns} | ||||||
|  |         pagination={false} | ||||||
|  |       /> | ||||||
|  |       <br /> | ||||||
|  |       <Upload | ||||||
|  |         name="emoji" | ||||||
|  |         listType="picture" | ||||||
|  |         className="emoji-uploader" | ||||||
|  |         showUploadList={false} | ||||||
|  |         accept={ACCEPTED_IMAGE_TYPES.join(',')} | ||||||
|  |         beforeUpload={setUploadFile} | ||||||
|  |         customRequest={handleEmojiUpload} | ||||||
|  |         disabled={loading} | ||||||
|  |       > | ||||||
|  |         <Button type="primary" disabled={loading}> | ||||||
|  |           Upload new emoji | ||||||
|  |         </Button> | ||||||
|  |       </Upload> | ||||||
|  |       <FormStatusIndicator status={submitStatus} /> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Emoji; | ||||||
| @ -64,6 +64,12 @@ export const CHAT_HISTORY = `${API_LOCATION}chat/messages`; | |||||||
| // Get chat history | // Get chat history | ||||||
| export const UPDATE_CHAT_MESSGAE_VIZ = `/api/admin/chat/messagevisibility`; | export const UPDATE_CHAT_MESSGAE_VIZ = `/api/admin/chat/messagevisibility`; | ||||||
|  |  | ||||||
|  | // Upload a new custom emoji | ||||||
|  | export const UPLOAD_EMOJI = `${API_LOCATION}emoji/upload`; | ||||||
|  |  | ||||||
|  | // Delete a custom emoji | ||||||
|  | export const DELETE_EMOJI = `${API_LOCATION}emoji/delete`; | ||||||
|  |  | ||||||
| // Get all access tokens | // Get all access tokens | ||||||
| export const ACCESS_TOKENS = `${API_LOCATION}accesstokens`; | export const ACCESS_TOKENS = `${API_LOCATION}accesstokens`; | ||||||
|  |  | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								web/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif']; | ||||||
|  |  | ||||||
|  | export function getBase64(img: File | Blob, callback: (imageUrl: string | ArrayBuffer) => void) { | ||||||
|  |   const reader = new FileReader(); | ||||||
|  |   reader.addEventListener('load', () => callback(reader.result)); | ||||||
|  |   reader.readAsDataURL(img); | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user
	 Philipp
					Philipp