Update.
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
:root {
|
||||
--primary-color: #003399;
|
||||
--secondary-color: #CC0000;
|
||||
--accent-color: #FFFFFF;
|
||||
--background-color: #F0F2F5;
|
||||
--text-color: #333333;
|
||||
--text-color-light: #666666;
|
||||
--border-color: #DDDDDD;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--sidebar-width: 250px;
|
||||
--header-height: 60px;
|
||||
--spacing-unit: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
color: var(--text-color);
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--accent-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 calc(var(--spacing-unit) * 2);
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin: 0 calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-unit);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--accent-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
margin: calc(var(--spacing-unit) * 2) 0 var(--spacing-unit) 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-light);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border: none;
|
||||
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: #AA0000;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-unit);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background-color: var(--accent-color);
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-box h1 {
|
||||
margin: 0 0 var(--spacing-unit) 0;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-color-light);
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-unit);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-tab.active {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.875rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.file-list-container {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.file-list-header h2 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.file-actions-menu {
|
||||
display: flex;
|
||||
gap: calc(var(--spacing-unit) / 2);
|
||||
margin-top: var(--spacing-unit);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);
|
||||
font-size: 0.75rem;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.upload-modal, .share-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.upload-modal-content, .share-modal-content {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-header, .share-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-header h3, .share-header h3 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color-light);
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.drop-zone-text p {
|
||||
margin: var(--spacing-unit) 0;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
margin-top: calc(var(--spacing-unit) * 2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
padding: var(--spacing-unit);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.upload-item-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.upload-item-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing-unit) / 2);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.upload-status.success {
|
||||
color: #00AA00;
|
||||
}
|
||||
|
||||
.upload-status.error {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.share-link-container input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
color: var(--text-color-light);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: var(--spacing-unit) 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.file-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
--background-color: #222222;
|
||||
--text-color: #EEEEEE;
|
||||
--text-color-light: #BBBBBB;
|
||||
--border-color: #444444;
|
||||
--shadow-color: rgba(255, 255, 255, 0.1);
|
||||
--accent-color: #333333;
|
||||
}
|
||||
|
||||
.shortcuts-help-content {
|
||||
background: var(--accent-color);
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
}
|
||||
|
||||
.shortcuts-help-content h2 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: calc(var(--spacing-unit) * 1);
|
||||
}
|
||||
|
||||
.shortcuts-help-content .button {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.shortcuts-list h3 {
|
||||
margin: calc(var(--spacing-unit) * 2) 0 calc(var(--spacing-unit) * 1) 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.shortcuts-list h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.shortcut-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcut-item kbd {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--spacing-unit) * 0.5) calc(var(--spacing-unit) * 1);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
}
|
||||
|
||||
.shortcut-item span {
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.photo-gallery {
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.gallery-header h2 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-info {
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
}
|
||||
|
||||
.photo-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: calc(var(--spacing-unit) * 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.photo-date {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
background: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-info h3 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 0.5) 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-info p {
|
||||
margin: 0;
|
||||
color: var(--text-color-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: calc(var(--spacing-unit) * 1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: calc(var(--spacing-unit) * 1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-content img,
|
||||
.preview-content video,
|
||||
.preview-content audio,
|
||||
.preview-content iframe {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-content iframe {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-content pre {
|
||||
background: var(--background-color);
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.no-preview {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.no-preview p {
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-results h2 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RBox Cloud Storage</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<rbox-app></rbox-app>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
class APIClient {
|
||||
constructor(baseURL = '/') {
|
||||
this.baseURL = baseURL;
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.token && !options.skipAuth) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (config.body && !(config.body instanceof FormData)) {
|
||||
config.body = JSON.stringify(config.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this.request('auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, email, password },
|
||||
skipAuth: true
|
||||
});
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const data = await this.request('auth/token', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
skipAuth: true
|
||||
});
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setToken(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
return this.request('users/me');
|
||||
}
|
||||
|
||||
async listFolders(parentId = null) {
|
||||
const params = parentId ? `?parent_id=${parentId}` : '';
|
||||
return this.request(`folders/${params}`);
|
||||
}
|
||||
|
||||
async createFolder(name, parentId = null) {
|
||||
return this.request('folders/', {
|
||||
method: 'POST',
|
||||
body: { name, parent_id: parentId }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFolder(folderId) {
|
||||
return this.request(`folders/${folderId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(file, folderId = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const endpoint = folderId ? `files/upload?folder_id=${folderId}` : 'files/upload';
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
async listFiles(folderId = null) {
|
||||
const params = folderId ? `?folder_id=${folderId}` : '';
|
||||
return this.request(`files/${params}`);
|
||||
}
|
||||
|
||||
async downloadFile(fileId) {
|
||||
const url = `${this.baseURL}files/download/${fileId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async deleteFile(fileId) {
|
||||
return this.request(`files/${fileId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async moveFile(fileId, targetFolderId) {
|
||||
return this.request(`files/${fileId}/move`, {
|
||||
method: 'POST',
|
||||
body: { target_folder_id: targetFolderId }
|
||||
});
|
||||
}
|
||||
|
||||
async renameFile(fileId, newName) {
|
||||
return this.request(`files/${fileId}/rename`, {
|
||||
method: 'POST',
|
||||
body: { new_name: newName }
|
||||
});
|
||||
}
|
||||
|
||||
async copyFile(fileId, targetFolderId) {
|
||||
return this.request(`files/${fileId}/copy`, {
|
||||
method: 'POST',
|
||||
body: { target_folder_id: targetFolderId }
|
||||
});
|
||||
}
|
||||
|
||||
async createShare(fileId = null, folderId = null, expiresAt = null, password = null, permissionLevel = 'viewer') {
|
||||
return this.request('shares/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
file_id: fileId,
|
||||
folder_id: folderId,
|
||||
expires_at: expiresAt,
|
||||
password,
|
||||
permission_level: permissionLevel
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteShare(shareId) {
|
||||
return this.request(`shares/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async searchFiles(query, filters = {}) {
|
||||
const params = new URLSearchParams({ q: query, ...filters });
|
||||
return this.request(`search/files?${params}`);
|
||||
}
|
||||
|
||||
async searchFolders(query) {
|
||||
return this.request(`search/folders?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async getPhotos() {
|
||||
return this.request('files/photos');
|
||||
}
|
||||
|
||||
getThumbnailUrl(fileId) {
|
||||
return `${this.baseURL}files/thumbnail/${fileId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new APIClient();
|
||||
@@ -0,0 +1,201 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class FileList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentFolderId = null;
|
||||
this.files = [];
|
||||
this.folders = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadContents(null);
|
||||
}
|
||||
|
||||
async loadContents(folderId) {
|
||||
this.currentFolderId = folderId;
|
||||
try {
|
||||
this.folders = await api.listFolders(folderId);
|
||||
this.files = await api.listFiles(folderId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load contents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(files) {
|
||||
this.files = files;
|
||||
this.folders = [];
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h2>Files</h2>
|
||||
<div class="file-actions">
|
||||
<button class="button" id="create-folder-btn">New Folder</button>
|
||||
<button class="button button-primary" id="upload-btn">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-grid">
|
||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||
${this.files.map(file => this.renderFile(file)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
return `
|
||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||
<div class="file-icon">📁</div>
|
||||
<div class="file-name">${folder.name}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-id="${file.id}">
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
|
||||
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
|
||||
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
|
||||
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelector('#upload-btn')?.addEventListener('click', () => {
|
||||
this.dispatchEvent(new CustomEvent('upload-request'));
|
||||
});
|
||||
|
||||
this.querySelector('#create-folder-btn')?.addEventListener('click', async () => {
|
||||
await this.handleCreateFolder();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.folder-item').forEach(item => {
|
||||
item.addEventListener('dblclick', () => {
|
||||
const folderId = parseInt(item.dataset.folderId);
|
||||
this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } }));
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (!e.target.classList.contains('action-btn')) {
|
||||
const fileId = parseInt(item.dataset.fileId);
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo: file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.action-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
await this.handleAction(action, id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
triggerCreateFolder() {
|
||||
this.handleCreateFolder();
|
||||
}
|
||||
|
||||
async handleCreateFolder() {
|
||||
const name = prompt('Enter folder name:');
|
||||
if (name) {
|
||||
try {
|
||||
await api.createFolder(name, this.currentFolderId);
|
||||
await this.loadContents(this.currentFolderId);
|
||||
} catch (error) {
|
||||
alert('Failed to create folder: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action, id) {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
const blob = await api.downloadFile(id);
|
||||
const file = this.files.find(f => f.id === id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
const newName = prompt('Enter new name:');
|
||||
if (newName) {
|
||||
await api.renameFile(id, newName);
|
||||
await this.loadContents(this.currentFolderId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
if (confirm('Are you sure you want to delete this file?')) {
|
||||
await api.deleteFile(id);
|
||||
await this.loadContents(this.currentFolderId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete-folder':
|
||||
if (confirm('Are you sure you want to delete this folder?')) {
|
||||
await api.deleteFolder(id);
|
||||
await this.loadContents(this.currentFolderId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'share':
|
||||
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Action failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-list', FileList);
|
||||
@@ -0,0 +1,174 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
class FilePreview extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.file = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const closeBtn = this.querySelector('.close-preview');
|
||||
const modal = this.querySelector('.preview-modal');
|
||||
const downloadBtn = this.querySelector('.download-btn');
|
||||
const shareBtn = this.querySelector('.share-btn');
|
||||
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) this.close();
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', () => this.downloadFile());
|
||||
shareBtn.addEventListener('click', () => this.shareFile());
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape' && this.style.display === 'block') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async show(file) {
|
||||
this.file = file;
|
||||
this.style.display = 'block';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
this.renderPreview();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.style.display = 'none';
|
||||
this.file = null;
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async renderPreview() {
|
||||
const previewContent = this.querySelector('.preview-content');
|
||||
const fileName = this.querySelector('.preview-file-name');
|
||||
const fileInfo = this.querySelector('.preview-file-info');
|
||||
|
||||
fileName.textContent = this.file.name;
|
||||
fileInfo.textContent = `${this.formatFileSize(this.file.size)} • ${new Date(this.file.created_at).toLocaleDateString()}`;
|
||||
|
||||
if (this.file.mime_type.startsWith('image/')) {
|
||||
previewContent.innerHTML = `<img alt="${this.file.name}">`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('img'));
|
||||
} else if (this.file.mime_type.startsWith('video/')) {
|
||||
previewContent.innerHTML = `
|
||||
<video controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('video'));
|
||||
} else if (this.file.mime_type.startsWith('audio/')) {
|
||||
previewContent.innerHTML = `
|
||||
<audio controls>
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('audio'));
|
||||
} else if (this.file.mime_type === 'application/pdf') {
|
||||
previewContent.innerHTML = `<iframe type="application/pdf"></iframe>`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('iframe'));
|
||||
} else if (this.file.mime_type.startsWith('text/')) {
|
||||
this.loadTextPreview(previewContent);
|
||||
} else {
|
||||
previewContent.innerHTML = `
|
||||
<div class="no-preview">
|
||||
<p>Preview not available for this file type</p>
|
||||
<button class="btn btn-primary" onclick="this.getRootNode().host.downloadFile()">Download File</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async loadAuthenticatedMedia(element) {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
element.src = url;
|
||||
} catch (error) {
|
||||
console.error('Failed to load media:', error);
|
||||
element.parentElement.innerHTML = '<p>Failed to load preview</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async loadTextPreview(container) {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const text = await blob.text();
|
||||
container.innerHTML = `<pre>${this.escapeHtml(text)}</pre>`;
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p>Failed to load preview</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile() {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = this.file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
shareFile() {
|
||||
this.dispatchEvent(new CustomEvent('share-file', {
|
||||
detail: { file: this.file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="preview-modal">
|
||||
<div class="preview-container">
|
||||
<div class="preview-header">
|
||||
<div class="preview-info">
|
||||
<h3 class="preview-file-name"></h3>
|
||||
<p class="preview-file-info"></p>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn btn-icon download-btn" title="Download">⬇</button>
|
||||
<button class="btn btn-icon share-btn" title="Share">🔗</button>
|
||||
<button class="btn btn-icon close-preview" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-preview', FilePreview);
|
||||
export { FilePreview };
|
||||
@@ -0,0 +1,125 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class FileUpload extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
setFolder(folderId) {
|
||||
this.folderId = folderId;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="upload-modal" id="upload-modal" style="display: none;">
|
||||
<div class="upload-modal-content">
|
||||
<div class="upload-header">
|
||||
<h3>Upload Files</h3>
|
||||
<button class="close-btn" id="close-upload">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<div class="drop-zone-text">
|
||||
<p>Drag and drop files here</p>
|
||||
<p>or</p>
|
||||
<button class="button button-primary" id="select-files-btn">Select Files</button>
|
||||
<input type="file" id="file-input" multiple style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-list" id="upload-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const modal = this.querySelector('#upload-modal');
|
||||
const dropZone = this.querySelector('#drop-zone');
|
||||
const fileInput = this.querySelector('#file-input');
|
||||
const selectBtn = this.querySelector('#select-files-btn');
|
||||
const closeBtn = this.querySelector('#close-upload');
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files));
|
||||
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
this.handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = this.querySelector('#upload-modal');
|
||||
if (modal.style.display === 'flex') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.querySelector('#upload-modal').style.display = 'flex';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.querySelector('#upload-modal').style.display = 'none';
|
||||
this.querySelector('#upload-list').innerHTML = '';
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async handleFiles(files) {
|
||||
const uploadList = this.querySelector('#upload-list');
|
||||
|
||||
for (const file of files) {
|
||||
const itemId = `upload-${Date.now()}-${Math.random()}`;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upload-item';
|
||||
item.id = itemId;
|
||||
item.innerHTML = `
|
||||
<div class="upload-item-name">${file.name}</div>
|
||||
<div class="upload-item-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="upload-status">Uploading...</span>
|
||||
</div>
|
||||
`;
|
||||
uploadList.appendChild(item);
|
||||
|
||||
try {
|
||||
await api.uploadFile(file, this.folderId);
|
||||
const status = item.querySelector('.upload-status');
|
||||
const progressFill = item.querySelector('.progress-fill');
|
||||
progressFill.style.width = '100%';
|
||||
status.textContent = 'Complete';
|
||||
status.classList.add('success');
|
||||
} catch (error) {
|
||||
const status = item.querySelector('.upload-status');
|
||||
status.textContent = 'Failed: ' + error.message;
|
||||
status.classList.add('error');
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('upload-complete'));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-upload', FileUpload);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class LoginView extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<h1>RBox</h1>
|
||||
<p class="auth-subtitle">Self-hosted cloud storage</p>
|
||||
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" data-tab="login">Login</button>
|
||||
<button class="auth-tab" data-tab="register">Register</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="auth-form">
|
||||
<input type="text" name="username" placeholder="Username" required class="input-field">
|
||||
<input type="password" name="password" placeholder="Password" required class="input-field">
|
||||
<button type="submit" class="button button-primary">Login</button>
|
||||
<div class="error-message" id="login-error"></div>
|
||||
</form>
|
||||
|
||||
<form id="register-form" class="auth-form" style="display: none;">
|
||||
<input type="text" name="username" placeholder="Username" required class="input-field">
|
||||
<input type="email" name="email" placeholder="Email" required class="input-field">
|
||||
<input type="password" name="password" placeholder="Password" required class="input-field">
|
||||
<button type="submit" class="button button-primary">Register</button>
|
||||
<div class="error-message" id="register-error"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const tabs = this.querySelectorAll('.auth-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
this.querySelector('#login-form').addEventListener('submit', (e) => this.handleLogin(e));
|
||||
this.querySelector('#register-form').addEventListener('submit', (e) => this.handleRegister(e));
|
||||
}
|
||||
|
||||
switchTab(tab) {
|
||||
const tabs = this.querySelectorAll('.auth-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
this.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||||
|
||||
const loginForm = this.querySelector('#login-form');
|
||||
const registerForm = this.querySelector('#register-form');
|
||||
|
||||
if (tab === 'login') {
|
||||
loginForm.style.display = 'block';
|
||||
registerForm.style.display = 'none';
|
||||
} else {
|
||||
loginForm.style.display = 'none';
|
||||
registerForm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('username');
|
||||
const password = formData.get('password');
|
||||
const errorDiv = this.querySelector('#login-error');
|
||||
|
||||
try {
|
||||
await api.login(username, password);
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRegister(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('username');
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
const errorDiv = this.querySelector('#register-error');
|
||||
|
||||
try {
|
||||
await api.register(username, email, password);
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('login-view', LoginView);
|
||||
@@ -0,0 +1,90 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
class PhotoGallery extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.photos = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadPhotos();
|
||||
}
|
||||
|
||||
async loadPhotos() {
|
||||
try {
|
||||
this.photos = await api.getPhotos();
|
||||
this.renderPhotos();
|
||||
} catch (error) {
|
||||
console.error('Failed to load photos:', error);
|
||||
this.querySelector('.gallery-grid').innerHTML = '<p>Failed to load photos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async renderPhotos() {
|
||||
const grid = this.querySelector('.gallery-grid');
|
||||
if (this.photos.length === 0) {
|
||||
grid.innerHTML = '<p class="empty-state">No photos yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = this.photos.map(photo => `
|
||||
<div class="photo-item" data-file-id="${photo.id}">
|
||||
<img
|
||||
data-photo-id="${photo.id}"
|
||||
alt="${photo.name}"
|
||||
loading="lazy"
|
||||
style="background: #f0f0f0;"
|
||||
>
|
||||
<div class="photo-info">
|
||||
<span class="photo-name">${photo.name}</span>
|
||||
<span class="photo-date">${new Date(photo.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
grid.querySelectorAll('img[data-photo-id]').forEach(async (img) => {
|
||||
const photoId = img.dataset.photoId;
|
||||
try {
|
||||
const response = await fetch(`/files/thumbnail/${photoId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${api.getToken()}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} else {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.photo-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const fileId = item.dataset.fileId;
|
||||
const photo = this.photos.find(p => p.id === parseInt(fileId));
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo },
|
||||
bubbles: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="photo-gallery">
|
||||
<div class="gallery-header">
|
||||
<h2>Photo Gallery</h2>
|
||||
</div>
|
||||
<div class="gallery-grid"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('photo-gallery', PhotoGallery);
|
||||
export { PhotoGallery };
|
||||
@@ -0,0 +1,345 @@
|
||||
import { api } from '../api.js';
|
||||
import './login-view.js';
|
||||
import './file-list.js';
|
||||
import './file-upload.js';
|
||||
import './share-modal.js';
|
||||
import './photo-gallery.js';
|
||||
import './file-preview.js';
|
||||
import { shortcuts } from '../shortcuts.js';
|
||||
|
||||
export class RBoxApp extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentView = 'files';
|
||||
this.currentFolderId = null;
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!api.getToken()) {
|
||||
this.showLogin();
|
||||
} else {
|
||||
try {
|
||||
this.user = await api.getCurrentUser();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
this.innerHTML = '<login-view></login-view>';
|
||||
const loginView = this.querySelector('login-view');
|
||||
loginView.addEventListener('auth-success', () => this.init());
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1 class="app-title">RBox</h1>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<input type="search" placeholder="Search..." class="search-input" id="search-input">
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-info">${this.user.username}</span>
|
||||
<button class="button" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-body">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<h3 class="nav-title">Navigation</h3>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#" class="nav-link active" data-view="files">My Files</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
||||
</ul>
|
||||
<h3 class="nav-title">Quick Access</h3>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#" class="nav-link" data-view="starred">Starred</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="recent">Recent</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
<div id="main-content">
|
||||
<file-list></file-list>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<file-upload></file-upload>
|
||||
<share-modal></share-modal>
|
||||
<file-preview></file-preview>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this.registerShortcuts();
|
||||
}
|
||||
|
||||
registerShortcuts() {
|
||||
shortcuts.register('ctrl+u', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+f', () => {
|
||||
const searchInput = this.querySelector('#search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+/', () => {
|
||||
this.showShortcutsHelp();
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+shift+n', () => {
|
||||
if (this.currentView === 'files') {
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.triggerCreateFolder();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('1', () => {
|
||||
this.switchView('files');
|
||||
});
|
||||
|
||||
shortcuts.register('2', () => {
|
||||
this.switchView('photos');
|
||||
});
|
||||
|
||||
shortcuts.register('3', () => {
|
||||
this.switchView('shared');
|
||||
});
|
||||
|
||||
shortcuts.register('4', () => {
|
||||
this.switchView('deleted');
|
||||
});
|
||||
|
||||
shortcuts.register('f2', () => {
|
||||
console.log('Rename shortcut - to be implemented');
|
||||
});
|
||||
}
|
||||
|
||||
showShortcutsHelp() {
|
||||
const helpContent = `
|
||||
<div class="shortcuts-help-modal">
|
||||
<div class="shortcuts-help-content">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div class="shortcuts-list">
|
||||
<h3>File Operations</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + U</kbd>
|
||||
<span>Upload files</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + Shift + N</kbd>
|
||||
<span>Create new folder</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + F</kbd>
|
||||
<span>Focus search</span>
|
||||
</div>
|
||||
|
||||
<h3>Navigation</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>1</kbd>
|
||||
<span>My Files</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>2</kbd>
|
||||
<span>Photo Gallery</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>3</kbd>
|
||||
<span>Shared Items</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>4</kbd>
|
||||
<span>Deleted Files</span>
|
||||
</div>
|
||||
|
||||
<h3>General</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>ESC</kbd>
|
||||
<span>Close modals</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + /</kbd>
|
||||
<span>Show this help</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button" id="close-shortcuts-help">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const helpDiv = document.createElement('div');
|
||||
helpDiv.innerHTML = helpContent;
|
||||
helpDiv.querySelector('.shortcuts-help-modal').style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
document.body.appendChild(helpDiv);
|
||||
|
||||
const closeHelp = () => {
|
||||
document.body.removeChild(helpDiv);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeHelp();
|
||||
}
|
||||
};
|
||||
|
||||
const closeBtn = helpDiv.querySelector('#close-shortcuts-help');
|
||||
closeBtn.addEventListener('click', closeHelp);
|
||||
helpDiv.querySelector('.shortcuts-help-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('shortcuts-help-modal')) closeHelp();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
||||
api.logout();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = link.dataset.view;
|
||||
this.switchView(view);
|
||||
});
|
||||
});
|
||||
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.addEventListener('upload-request', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
});
|
||||
|
||||
fileList.addEventListener('folder-open', (e) => {
|
||||
this.currentFolderId = e.detail.folderId;
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
});
|
||||
|
||||
fileList.addEventListener('share-request', (e) => {
|
||||
const modal = this.querySelector('share-modal');
|
||||
modal.show(e.detail.fileId);
|
||||
});
|
||||
}
|
||||
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.addEventListener('upload-complete', () => {
|
||||
const fileList = this.querySelector('file-list');
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = this.querySelector('#search-input');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = e.target.value.trim();
|
||||
if (query.length > 0) {
|
||||
searchTimeout = setTimeout(() => this.performSearch(query), 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.addEventListener('photo-click', (e) => {
|
||||
const preview = this.querySelector('file-preview');
|
||||
preview.show(e.detail.photo);
|
||||
});
|
||||
|
||||
this.addEventListener('share-file', (e) => {
|
||||
const modal = this.querySelector('share-modal');
|
||||
modal.show(e.detail.file.id);
|
||||
});
|
||||
}
|
||||
|
||||
async performSearch(query) {
|
||||
try {
|
||||
const files = await api.searchFiles(query);
|
||||
const mainContent = this.querySelector('#main-content');
|
||||
mainContent.innerHTML = `
|
||||
<div class="search-results">
|
||||
<h2>Search Results for "${query}"</h2>
|
||||
<file-list data-search-mode="true"></file-list>
|
||||
</div>
|
||||
`;
|
||||
const fileList = mainContent.querySelector('file-list');
|
||||
fileList.setFiles(files);
|
||||
this.attachListeners();
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
switchView(view) {
|
||||
this.currentView = view;
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
this.querySelector(`[data-view="${view}"]`)?.classList.add('active');
|
||||
|
||||
const mainContent = this.querySelector('#main-content');
|
||||
|
||||
switch (view) {
|
||||
case 'files':
|
||||
mainContent.innerHTML = '<file-list></file-list>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'photos':
|
||||
mainContent.innerHTML = '<photo-gallery></photo-gallery>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'shared':
|
||||
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'deleted':
|
||||
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
|
||||
break;
|
||||
case 'starred':
|
||||
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'recent':
|
||||
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class ShareModal extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.fileId = null;
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="share-modal" id="share-modal" style="display: none;">
|
||||
<div class="share-modal-content">
|
||||
<div class="share-header">
|
||||
<h3>Share</h3>
|
||||
<button class="close-btn" id="close-share">×</button>
|
||||
</div>
|
||||
|
||||
<form id="share-form">
|
||||
<div class="form-group">
|
||||
<label>Permission Level</label>
|
||||
<select name="permission_level" class="input-field">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="uploader">Uploader</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password Protection (optional)</label>
|
||||
<input type="password" name="password" class="input-field" placeholder="Leave blank for no password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Expiration Date (optional)</label>
|
||||
<input type="datetime-local" name="expires_at" class="input-field">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button button-primary">Create Share Link</button>
|
||||
</form>
|
||||
|
||||
<div id="share-result" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Share Link</label>
|
||||
<div class="share-link-container">
|
||||
<input type="text" id="share-link" readonly class="input-field">
|
||||
<button class="button" id="copy-link-btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="share-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const closeBtn = this.querySelector('#close-share');
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
|
||||
const form = this.querySelector('#share-form');
|
||||
form.addEventListener('submit', (e) => this.handleCreateShare(e));
|
||||
|
||||
const copyBtn = this.querySelector('#copy-link-btn');
|
||||
copyBtn.addEventListener('click', () => this.copyLink());
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = this.querySelector('#share-modal');
|
||||
if (modal.style.display === 'flex') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show(fileId = null, folderId = null) {
|
||||
this.fileId = fileId;
|
||||
this.folderId = folderId;
|
||||
this.querySelector('#share-modal').style.display = 'flex';
|
||||
this.querySelector('#share-result').style.display = 'none';
|
||||
this.querySelector('#share-form').reset();
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.querySelector('#share-modal').style.display = 'none';
|
||||
this.fileId = null;
|
||||
this.folderId = null;
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async handleCreateShare(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const errorDiv = this.querySelector('#share-error');
|
||||
|
||||
try {
|
||||
const permissionLevel = formData.get('permission_level');
|
||||
const password = formData.get('password') || null;
|
||||
const expiresAt = formData.get('expires_at') || null;
|
||||
|
||||
const share = await api.createShare(
|
||||
this.fileId,
|
||||
this.folderId,
|
||||
expiresAt,
|
||||
password,
|
||||
permissionLevel
|
||||
);
|
||||
|
||||
const shareLink = `${window.location.origin}/share/${share.token}`;
|
||||
this.querySelector('#share-link').value = shareLink;
|
||||
this.querySelector('#share-result').style.display = 'block';
|
||||
errorDiv.textContent = '';
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Failed to create share link: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
copyLink() {
|
||||
const linkInput = this.querySelector('#share-link');
|
||||
linkInput.select();
|
||||
document.execCommand('copy');
|
||||
alert('Link copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('share-modal', ShareModal);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RBoxApp } from './components/rbox-app.js';
|
||||
|
||||
// Define the custom element
|
||||
customElements.define('rbox-app', RBoxApp);
|
||||
|
||||
// Instantiate the main application class
|
||||
const app = new RBoxApp();
|
||||
|
||||
// Append the app to the body (if not already in index.html)
|
||||
// document.body.appendChild(app);
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered with scope:', registration.scope);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
class KeyboardShortcuts {
|
||||
constructor() {
|
||||
this.shortcuts = new Map();
|
||||
this.enabled = true;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const isInput = ['INPUT', 'TEXTAREA'].includes(e.target.tagName);
|
||||
const key = this.getKeyCombo(e);
|
||||
|
||||
if (this.shortcuts.has(key)) {
|
||||
const { handler, allowInInput } = this.shortcuts.get(key);
|
||||
if (!isInput || allowInInput) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getKeyCombo(e) {
|
||||
const parts = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
|
||||
if (e.altKey) parts.push('alt');
|
||||
if (e.shiftKey) parts.push('shift');
|
||||
parts.push(e.key.toLowerCase());
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
register(keyCombo, handler, allowInInput = false) {
|
||||
this.shortcuts.set(keyCombo.toLowerCase(), { handler, allowInInput });
|
||||
}
|
||||
|
||||
unregister(keyCombo) {
|
||||
this.shortcuts.delete(keyCombo.toLowerCase());
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
getRegisteredShortcuts() {
|
||||
return Array.from(this.shortcuts.keys());
|
||||
}
|
||||
}
|
||||
|
||||
export const shortcuts = new KeyboardShortcuts();
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "RBox Cloud Storage",
|
||||
"short_name": "RBox",
|
||||
"description": "A self-hosted cloud storage web application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F0F2F5",
|
||||
"theme_color": "#003399",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const CACHE_NAME = 'rbox-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/static/index.html',
|
||||
'/static/css/style.css',
|
||||
'/static/js/main.js',
|
||||
'/static/js/components/rbox-app.js',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user