WIP Upload multiple photos
This commit is contained in:
164
app/components/place-photo-item.gjs
Normal file
164
app/components/place-photo-item.gjs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import { task } from 'ember-concurrency';
|
||||||
|
import { EventFactory } from 'applesauce-core';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
|
||||||
|
const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
|
||||||
|
|
||||||
|
function bufferToHex(buffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PlacePhotoItem extends Component {
|
||||||
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
|
@tracked thumbnailUrl = '';
|
||||||
|
@tracked error = '';
|
||||||
|
@tracked isUploaded = false;
|
||||||
|
|
||||||
|
get blossomServer() {
|
||||||
|
return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
if (this.args.file) {
|
||||||
|
this.thumbnailUrl = URL.createObjectURL(this.args.file);
|
||||||
|
this.uploadTask.perform(this.args.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
super.willDestroy(...arguments);
|
||||||
|
if (this.thumbnailUrl) {
|
||||||
|
URL.revokeObjectURL(this.thumbnailUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTask = task(async (file) => {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
if (!this.nostrAuth.isConnected) throw new Error('Not connected');
|
||||||
|
|
||||||
|
const dim = await new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(`${img.width}x${img.height}`);
|
||||||
|
img.onerror = () => resolve('');
|
||||||
|
img.src = this.thumbnailUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
const payloadHash = bufferToHex(hashBuffer);
|
||||||
|
|
||||||
|
let serverUrl = this.blossomServer;
|
||||||
|
if (serverUrl.endsWith('/')) {
|
||||||
|
serverUrl = serverUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
const uploadUrl = `${serverUrl}/upload`;
|
||||||
|
|
||||||
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const serverHostname = new URL(serverUrl).hostname;
|
||||||
|
|
||||||
|
const authTemplate = {
|
||||||
|
kind: 24242,
|
||||||
|
created_at: now,
|
||||||
|
content: 'Upload photo for place',
|
||||||
|
tags: [
|
||||||
|
['t', 'upload'],
|
||||||
|
['x', payloadHash],
|
||||||
|
['expiration', String(now + 3600)],
|
||||||
|
['server', serverHostname],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const authEvent = await factory.sign(authTemplate);
|
||||||
|
const base64 = btoa(JSON.stringify(authEvent));
|
||||||
|
const base64url = base64
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
const authHeader = `Nostr ${base64url}`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'X-SHA-256': payloadHash,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
this.isUploaded = true;
|
||||||
|
|
||||||
|
if (this.args.onSuccess) {
|
||||||
|
this.args.onSuccess({
|
||||||
|
file,
|
||||||
|
url: result.url,
|
||||||
|
type: file.type,
|
||||||
|
dim,
|
||||||
|
hash: payloadHash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="photo-item
|
||||||
|
{{if this.uploadTask.isRunning 'is-uploading'}}
|
||||||
|
{{if this.error 'has-error'}}"
|
||||||
|
>
|
||||||
|
<img src={{this.thumbnailUrl}} alt="thumbnail" class="photo-item-img" />
|
||||||
|
|
||||||
|
{{#if this.uploadTask.isRunning}}
|
||||||
|
<div class="photo-item-overlay">
|
||||||
|
<Icon
|
||||||
|
@name="loading-ring"
|
||||||
|
@size={{24}}
|
||||||
|
@color="white"
|
||||||
|
class="spin-animation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.error}}
|
||||||
|
<div class="photo-item-overlay error-overlay" title={{this.error}}>
|
||||||
|
<Icon @name="alert-circle" @size={{24}} @color="white" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.isUploaded}}
|
||||||
|
<div class="photo-item-overlay success-overlay">
|
||||||
|
<Icon @name="check" @size={{24}} @color="white" />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-remove-photo"
|
||||||
|
title="Remove photo"
|
||||||
|
{{on "click" (fn @onRemove @file)}}
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{16}} @color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -5,26 +5,20 @@ import { inject as service } from '@ember/service';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { EventFactory } from 'applesauce-core';
|
import { EventFactory } from 'applesauce-core';
|
||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
|
import PlacePhotoItem from './place-photo-item';
|
||||||
const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build';
|
import Icon from '#components/icon';
|
||||||
|
import { or, not } from 'ember-truth-helpers';
|
||||||
function bufferToHex(buffer) {
|
|
||||||
return Array.from(new Uint8Array(buffer))
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class PlacePhotoUpload extends Component {
|
export default class PlacePhotoUpload extends Component {
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrData;
|
|
||||||
@service nostrRelay;
|
@service nostrRelay;
|
||||||
|
|
||||||
@tracked photoUrl = '';
|
@tracked files = [];
|
||||||
@tracked photoType = 'image/jpeg';
|
@tracked uploadedPhotos = [];
|
||||||
@tracked photoDim = '';
|
|
||||||
@tracked status = '';
|
@tracked status = '';
|
||||||
@tracked error = '';
|
@tracked error = '';
|
||||||
@tracked isUploading = false;
|
@tracked isPublishing = false;
|
||||||
|
@tracked isDragging = false;
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
@@ -34,106 +28,56 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
return this.place.title || 'this place';
|
return this.place.title || 'this place';
|
||||||
}
|
}
|
||||||
|
|
||||||
get blossomServer() {
|
get allUploaded() {
|
||||||
return this.nostrData.blossomServers[0] || DEFAULT_BLOSSOM_SERVER;
|
return (
|
||||||
|
this.files.length > 0 && this.files.length === this.uploadedPhotos.length
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async handleFileSelected(event) {
|
handleFileSelect(event) {
|
||||||
const file = event.target.files[0];
|
this.addFiles(event.target.files);
|
||||||
if (!file) return;
|
event.target.value = ''; // Reset input
|
||||||
|
|
||||||
this.error = '';
|
|
||||||
this.status = 'Preparing upload...';
|
|
||||||
this.isUploading = true;
|
|
||||||
this.photoType = file.type;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.nostrAuth.isConnected) {
|
|
||||||
throw new Error('You must connect Nostr first.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Get image dimensions
|
@action
|
||||||
const dim = await new Promise((resolve) => {
|
handleDragOver(event) {
|
||||||
const url = URL.createObjectURL(file);
|
event.preventDefault();
|
||||||
const img = new Image();
|
this.isDragging = true;
|
||||||
img.onload = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(`${img.width}x${img.height}`);
|
|
||||||
};
|
|
||||||
img.onerror = () => resolve('');
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
this.photoDim = dim;
|
|
||||||
|
|
||||||
// 2. Read file & compute hash
|
|
||||||
this.status = 'Computing hash...';
|
|
||||||
const buffer = await file.arrayBuffer();
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
||||||
const payloadHash = bufferToHex(hashBuffer);
|
|
||||||
|
|
||||||
// 3. Create BUD-11 Auth Event
|
|
||||||
this.status = 'Signing auth event...';
|
|
||||||
let serverUrl = this.blossomServer;
|
|
||||||
if (serverUrl.endsWith('/')) {
|
|
||||||
serverUrl = serverUrl.slice(0, -1);
|
|
||||||
}
|
|
||||||
const uploadUrl = `${serverUrl}/upload`;
|
|
||||||
|
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const serverHostname = new URL(serverUrl).hostname;
|
|
||||||
|
|
||||||
const authTemplate = {
|
|
||||||
kind: 24242,
|
|
||||||
created_at: now,
|
|
||||||
content: 'Upload photo for place',
|
|
||||||
tags: [
|
|
||||||
['t', 'upload'],
|
|
||||||
['x', payloadHash],
|
|
||||||
['expiration', String(now + 3600)],
|
|
||||||
['server', serverHostname],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const authEvent = await factory.sign(authTemplate);
|
|
||||||
const base64 = btoa(JSON.stringify(authEvent));
|
|
||||||
const base64url = base64
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '');
|
|
||||||
const authHeader = `Nostr ${base64url}`;
|
|
||||||
|
|
||||||
// 4. Upload to Blossom
|
|
||||||
this.status = `Uploading to ${serverUrl}...`;
|
|
||||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
|
||||||
const response = await fetch(uploadUrl, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Authorization: authHeader,
|
|
||||||
'X-SHA-256': payloadHash,
|
|
||||||
},
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
@action
|
||||||
|
handleDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.photoUrl = result.url;
|
@action
|
||||||
this.status = 'Photo uploaded! Ready to publish.';
|
handleDrop(event) {
|
||||||
} catch (e) {
|
event.preventDefault();
|
||||||
this.error = e.message;
|
this.isDragging = false;
|
||||||
this.status = '';
|
this.addFiles(event.dataTransfer.files);
|
||||||
} finally {
|
|
||||||
this.isUploading = false;
|
|
||||||
if (event && event.target) {
|
|
||||||
event.target.value = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addFiles(fileList) {
|
||||||
|
if (!fileList) return;
|
||||||
|
const newFiles = Array.from(fileList).filter((f) =>
|
||||||
|
f.type.startsWith('image/')
|
||||||
|
);
|
||||||
|
this.files = [...this.files, ...newFiles];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleUploadSuccess(photoData) {
|
||||||
|
this.uploadedPhotos = [...this.uploadedPhotos, photoData];
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
removeFile(fileToRemove) {
|
||||||
|
this.files = this.files.filter((f) => f !== fileToRemove);
|
||||||
|
this.uploadedPhotos = this.uploadedPhotos.filter(
|
||||||
|
(p) => p.file !== fileToRemove
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -143,8 +87,8 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.photoUrl) {
|
if (!this.allUploaded) {
|
||||||
this.error = 'Please upload a photo.';
|
this.error = 'Please wait for all photos to finish uploading.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +102,7 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
|
|
||||||
this.status = 'Publishing event...';
|
this.status = 'Publishing event...';
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.isPublishing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
const factory = new EventFactory({ signer: this.nostrAuth.signer });
|
||||||
@@ -171,18 +116,20 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
tags.push(['g', Geohash.encode(lat, lon, 9)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const photo of this.uploadedPhotos) {
|
||||||
const imeta = [
|
const imeta = [
|
||||||
'imeta',
|
'imeta',
|
||||||
`url ${this.photoUrl}`,
|
`url ${photo.url}`,
|
||||||
`m ${this.photoType}`,
|
`m ${photo.type}`,
|
||||||
'alt A photo of a place',
|
'alt A photo of a place',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.photoDim) {
|
if (photo.dim) {
|
||||||
imeta.splice(3, 0, `dim ${this.photoDim}`);
|
imeta.splice(3, 0, `dim ${photo.dim}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.push(imeta);
|
tags.push(imeta);
|
||||||
|
}
|
||||||
|
|
||||||
// NIP-XX draft Place Photo event
|
// NIP-XX draft Place Photo event
|
||||||
const template = {
|
const template = {
|
||||||
@@ -199,16 +146,21 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
await this.nostrRelay.publish(event);
|
await this.nostrRelay.publish(event);
|
||||||
|
|
||||||
this.status = 'Published successfully!';
|
this.status = 'Published successfully!';
|
||||||
this.photoUrl = '';
|
|
||||||
|
// Clear out the files so user can upload more or be done
|
||||||
|
this.files = [];
|
||||||
|
this.uploadedPhotos = [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = 'Failed to publish: ' + e.message;
|
this.error = 'Failed to publish: ' + e.message;
|
||||||
this.status = '';
|
this.status = '';
|
||||||
|
} finally {
|
||||||
|
this.isPublishing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-photo-upload">
|
<div class="place-photo-upload">
|
||||||
<h2>Add Photo for {{this.title}}</h2>
|
<h2>Add Photos for {{this.title}}</h2>
|
||||||
|
|
||||||
{{#if this.error}}
|
{{#if this.error}}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
@@ -222,38 +174,53 @@ export default class PlacePhotoUpload extends Component {
|
|||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
{{#if this.photoUrl}}
|
class="dropzone {{if this.isDragging 'is-dragging'}}"
|
||||||
<div class="preview-group">
|
{{on "dragover" this.handleDragOver}}
|
||||||
<p>Photo Preview:</p>
|
{{on "dragleave" this.handleDragLeave}}
|
||||||
<img
|
{{on "drop" this.handleDrop}}
|
||||||
src={{this.photoUrl}}
|
|
||||||
alt="Preview"
|
|
||||||
class="photo-preview-img"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
{{on "click" this.publish}}
|
|
||||||
>
|
>
|
||||||
Publish Event (kind: 360)
|
<label for="photo-upload-input" class="dropzone-label">
|
||||||
</button>
|
<Icon @name="upload-cloud" @size={{48}} />
|
||||||
{{else}}
|
<p>Drag and drop photos here, or click to browse</p>
|
||||||
<label for="photo-upload-input">Select Photo</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="photo-upload-input"
|
id="photo-upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="file-input"
|
multiple
|
||||||
disabled={{this.isUploading}}
|
class="file-input-hidden"
|
||||||
{{on "change" this.handleFileSelected}}
|
disabled={{this.isPublishing}}
|
||||||
|
{{on "change" this.handleFileSelect}}
|
||||||
/>
|
/>
|
||||||
{{#if this.isUploading}}
|
|
||||||
<p>Uploading...</p>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.files.length}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{#each this.files as |file|}}
|
||||||
|
<PlacePhotoItem
|
||||||
|
@file={{file}}
|
||||||
|
@onSuccess={{this.handleUploadSuccess}}
|
||||||
|
@onRemove={{this.removeFile}}
|
||||||
|
/>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-publish"
|
||||||
|
disabled={{or (not this.allUploaded) this.isPublishing}}
|
||||||
|
{{on "click" this.publish}}
|
||||||
|
>
|
||||||
|
{{#if this.isPublishing}}
|
||||||
|
Publishing...
|
||||||
|
{{else}}
|
||||||
|
Publish
|
||||||
|
{{this.files.length}}
|
||||||
|
Photo(s)
|
||||||
|
{{/if}}
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,118 @@ body {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropzone {
|
||||||
|
border: 2px dashed #3a4b5c;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background-color: rgb(255 255 255 / 2%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.is-dragging {
|
||||||
|
border-color: #61afef;
|
||||||
|
background-color: rgb(97 175 239 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone-label p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e262e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(0 0 0 / 60%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay {
|
||||||
|
background: rgb(224 108 117 / 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-overlay {
|
||||||
|
background: rgb(152 195 121 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-photo {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgb(0 0 0 / 70%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: white;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-photo:hover {
|
||||||
|
background: rgb(224 108 117 / 90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-publish {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* User Menu Popover */
|
/* User Menu Popover */
|
||||||
.user-menu-container {
|
.user-menu-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -26,8 +26,12 @@ import search from 'feather-icons/dist/icons/search.svg?raw';
|
|||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
|
import trash2 from 'feather-icons/dist/icons/trash-2.svg?raw';
|
||||||
|
import uploadCloud from 'feather-icons/dist/icons/upload-cloud.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
|
import check from 'feather-icons/dist/icons/check.svg?raw';
|
||||||
|
import alertCircle from 'feather-icons/dist/icons/alert-circle.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
@@ -130,6 +134,8 @@ const ICONS = {
|
|||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
climbing_wall: climbingWall,
|
climbing_wall: climbingWall,
|
||||||
|
check,
|
||||||
|
'alert-circle': alertCircle,
|
||||||
'classical-building': classicalBuilding,
|
'classical-building': classicalBuilding,
|
||||||
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
'classical-building-with-flag': classicalBuildingWithFlag,
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
@@ -214,6 +220,8 @@ const ICONS = {
|
|||||||
'tattoo-machine': tattooMachine,
|
'tattoo-machine': tattooMachine,
|
||||||
toolbox,
|
toolbox,
|
||||||
target,
|
target,
|
||||||
|
'trash-2': trash2,
|
||||||
|
'upload-cloud': uploadCloud,
|
||||||
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
'village-buildings': villageBuildings,
|
'village-buildings': villageBuildings,
|
||||||
|
|||||||
Reference in New Issue
Block a user