Browse Source

hella cleanup - index page items; use more Row/Cols to reduce custom css layout

pull/1886/head
gingervitis 4 years ago
parent
commit
8d5411a0d6
  1. 2
      web/components/config/README.md
  2. 54
      web/components/config/edit-social-links.tsx
  3. 64
      web/components/config/social-icons-dropdown.tsx
  4. 4
      web/components/config/video-variant-form.tsx
  5. 2
      web/components/log-table.tsx
  6. 2
      web/components/main-layout.tsx
  7. 2
      web/pages/chat.tsx
  8. 6
      web/pages/config-video.tsx
  9. 4
      web/pages/hardware-info.tsx
  10. 139
      web/pages/index.tsx
  11. 17
      web/pages/offline-notice.tsx
  12. 8
      web/pages/viewer-info.tsx
  13. 54
      web/styles/ant-overrides.scss
  14. 6
      web/styles/config-public-details.scss
  15. 2
      web/styles/config-socialhandles.scss
  16. 169
      web/styles/home.scss
  17. 16
      web/styles/main-layout.scss

2
web/components/config/README.md

@ -42,3 +42,5 @@ There are also a variety of other local states to manage the display of error/su @@ -42,3 +42,5 @@ There are also a variety of other local states to manage the display of error/su
segment-slider-container
selected-value-note

54
web/components/config/edit-social-links.tsx

@ -221,6 +221,19 @@ export default function EditSocialLinks() { @@ -221,6 +221,19 @@ export default function EditSocialLinks() {
disabled: !isValidUrl(modalDataState.url),
};
const otherField = (
<div className="other-field-container formfield-container">
<div className="label-side" />
<div className="input-side">
<Input
placeholder="Other platform name"
defaultValue={modalDataState.platform}
onChange={handleOtherNameChange}
/>
</div>
</div>
);
return (
<div className="social-links-edit-container">
<Title level={3} className="section-title">
@ -249,30 +262,23 @@ export default function EditSocialLinks() { @@ -249,30 +262,23 @@ export default function EditSocialLinks() {
confirmLoading={modalProcessing}
okButtonProps={okButtonProps}
>
<SocialDropdown
iconList={availableIconsList}
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
onSelected={handleDropdownSelect}
/>
{displayOther ? (
<>
<Input
placeholder="Other"
defaultValue={modalDataState.platform}
onChange={handleOtherNameChange}
/>
<br />
</>
) : null}
<br />
<TextField
fieldName="social-url"
label="URL"
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url}
onChange={handleUrlChange}
/>
<FormStatusIndicator status={submitStatus} />
<div className="social-handle-modal-content">
<SocialDropdown
iconList={availableIconsList}
selectedOption={selectedOther ? OTHER_SOCIAL_HANDLE_OPTION : modalDataState.platform}
onSelected={handleDropdownSelect}
/>
{displayOther && otherField}
<br />
<TextField
fieldName="social-url"
label="URL"
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url}
onChange={handleUrlChange}
/>
<FormStatusIndicator status={submitStatus} />
</div>
</Modal>
<br />
<Button

64
web/components/config/social-icons-dropdown.tsx

@ -23,39 +23,41 @@ export default function SocialDropdown({ iconList, selectedOption, onSelected }: @@ -23,39 +23,41 @@ export default function SocialDropdown({ iconList, selectedOption, onSelected }:
If you are looking for a platform name not on this list, please select Other and type in
your own name. A logo will not be provided.
</p>
<p className="description">
If you DO have a logo, drop it in to the <code>/webroot/img/platformicons</code> directory
and update the <code>/socialHandle.go</code> list. Then restart the server and it will show
up in the list.
</p>
<Select
style={{ width: 240 }}
className="social-dropdown"
placeholder="Social platform..."
defaultValue={inititalSelected}
value={inititalSelected}
onSelect={handleSelected}
>
{iconList.map(item => {
const { platform, icon, key } = item;
return (
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
<span className="option-icon">
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
</span>
<span className="option-label">{platform}</span>
<div className="formfield-container">
<div className="label-side">
<span className="formfield-label">Social Platform</span>
</div>
<div className="input-side">
<Select
style={{ width: 240 }}
className="social-dropdown"
placeholder="Social platform..."
defaultValue={inititalSelected}
value={inititalSelected}
onSelect={handleSelected}
>
{iconList.map(item => {
const { platform, icon, key } = item;
return (
<Select.Option className="social-option" key={`platform-${key}`} value={key}>
<span className="option-icon">
<img src={`${NEXT_PUBLIC_API_HOST}${icon}`} alt="" className="option-icon" />
</span>
<span className="option-label">{platform}</span>
</Select.Option>
);
})}
<Select.Option
className="social-option"
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
value={OTHER_SOCIAL_HANDLE_OPTION}
>
Other...
</Select.Option>
);
})}
<Select.Option
className="social-option"
key={`platform-${OTHER_SOCIAL_HANDLE_OPTION}`}
value={OTHER_SOCIAL_HANDLE_OPTION}
>
Other...
</Select.Option>
</Select>
</Select>
</div>
</div>
</div>
);
}

4
web/components/config/video-variant-form.tsx

@ -156,7 +156,7 @@ export default function VideoVariantForm({ @@ -156,7 +156,7 @@ export default function VideoVariantForm({
</p>
<Row gutter={16}>
<Col xs={12} xl={12}>
<Col sm={24} md={12}>
{/* ENCODER PRESET FIELD */}
<div className="form-module cpu-usage-container">
<CPUUsageSelector
@ -177,7 +177,7 @@ export default function VideoVariantForm({ @@ -177,7 +177,7 @@ export default function VideoVariantForm({
</div>
</Col>
<Col xs={12} xl={12}>
<Col sm={24} md={12}>
{/* VIDEO BITRATE FIELD */}
<div
className={`form-module bitrate-container ${

2
web/components/log-table.tsx

@ -75,7 +75,7 @@ export default function LogTable({ logs, pageSize }: Props) { @@ -75,7 +75,7 @@ export default function LogTable({ logs, pageSize }: Props) {
return (
<div className="logs-section">
<Title level={2}>Logs</Title>
<Title>Logs</Title>
<Table
size="middle"
dataSource={logs}

2
web/components/main-layout.tsx

@ -194,7 +194,7 @@ export default function MainLayout(props) { @@ -194,7 +194,7 @@ export default function MainLayout(props) {
<TextFieldWithSubmit
fieldName="streamTitle"
{...TEXTFIELD_PROPS_STREAM_TITLE}
placeholder="What you're streaming right now"
placeholder="What are you streaming now"
value={currentStreamTitle}
initialValue={instanceDetails.streamTitle}
onChange={handleStreamTitleChanged}

2
web/pages/chat.tsx

@ -202,7 +202,7 @@ export default function Chat() { @@ -202,7 +202,7 @@ export default function Chat() {
return (
<div className="chat-messages">
<Title level={2}>Chat Messages</Title>
<Title>Chat Messages</Title>
<p>Manage the messages from viewers that show up on your stream.</p>
<div className={bulkDivClasses}>
<span className="label">Check multiple messages to change their visibility to: </span>

6
web/pages/config-video.tsx

@ -16,13 +16,13 @@ export default function ConfigVideoSettings() { @@ -16,13 +16,13 @@ export default function ConfigVideoSettings() {
how it impacts your stream performance.
</p>
<Row gutter={16}>
<Col xl={12}>
<Row gutter={[16, 16]}>
<Col md={24} lg={12}>
<div className="form-module variants-table-module">
<VideoVariantsTable />
</div>
</Col>
<Col xl={12}>
<Col md={24} lg={12}>
<div className="form-module latency-module">
<VideoLatency />
</div>

4
web/pages/hardware-info.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
import { Row } from 'antd';
import { Row, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../utils/apis';
import Chart from '../components/chart';
@ -67,6 +67,8 @@ export default function HardwareInfo() { @@ -67,6 +67,8 @@ export default function HardwareInfo() {
return (
<div>
<Typography.Title>Hardware Info</Typography.Title>
<br />
<div>
<Row gutter={[16, 16]} justify="space-around">
<StatisticItem

139
web/pages/index.tsx

@ -1,24 +1,13 @@ @@ -1,24 +1,13 @@
/*
Will display an overview with the following datasources:
1. Current broadcaster.
2. Viewer count.
3. Video settings.
TODO: Link each overview value to the sub-page that focuses on it.
*/
import React, { useState, useEffect, useContext } from 'react';
import { Skeleton, Card, Statistic } from 'antd';
import { Skeleton, Card, Statistic, Row, Col } from 'antd';
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { formatDistanceToNow, formatRelative } from 'date-fns';
import { ServerStatusContext } from '../utils/server-status-context';
import StatisticItem from '../components/statistic';
import LogTable from '../components/log-table';
import Offline from './offline-notice';
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../utils/apis';
import { formatIPAddress, isEmptyObject } from '../utils/format';
import { UpdateArgs } from '../types/config-section';
function streamDetailsFormatter(streamDetails) {
return (
@ -80,31 +69,34 @@ export default function Home() { @@ -80,31 +69,34 @@ export default function Home() {
}
// map out settings
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(
(setting, index) => {
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
const audioSetting = audioPassthrough
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
: `${audioBitrate || 'Unknown'} kbps`;
const videoSetting = videoPassthrough
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
streamDetails.width
} x ${streamDetails.height}`
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
let settingTitle = 'Outbound Stream Details';
settingTitle =
videoQualitySettings?.length > 1 ? `${settingTitle} ${index + 1}` : settingTitle;
return (
<Card title={settingTitle} type="inner" key={`${settingTitle}${index}`}>
<StatisticItem title="Outbound Video Stream" value={videoSetting} prefix={null} />
<StatisticItem title="Outbound Audio Stream" value={audioSetting} prefix={null} />
</Card>
);
},
);
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(setting => {
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
const audioSetting = audioPassthrough
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
: `${audioBitrate || 'Unknown'} kbps`;
const videoSetting = videoPassthrough
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
streamDetails.width
} x ${streamDetails.height}`
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
return (
<div className="stream-details-item-container">
<Statistic
className="stream-details-item"
title="Outbound Video Stream"
value={videoSetting}
/>
<Statistic
className="stream-details-item"
title="Outbound Audio Stream"
value={audioSetting}
/>
</div>
);
});
// inbound
const { viewerCount, sessionPeakViewerCount } = serverStatusData;
@ -118,57 +110,60 @@ export default function Home() { @@ -118,57 +110,60 @@ export default function Home() {
return (
<div className="home-container">
<div className="sections-container">
<div className="section online-status-section">
<Card title="Stream is online" type="inner">
<Statistic
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
value={formatDistanceToNow(broadcastDate)}
prefix={<ClockCircleOutlined />}
/>
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
<Statistic
title="Peak viewer count"
value={sessionPeakViewerCount}
prefix={<UserOutlined />}
/>
<div className="online-status-section">
<Card size="small" type="inner" className="online-details-card">
<Row gutter={[16, 16]} align="middle">
<Col span={8} sm={24} md={8}>
<Statistic
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
value={formatDistanceToNow(broadcastDate)}
prefix={<ClockCircleOutlined />}
/>
</Col>
<Col span={8} sm={24} md={8}>
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
</Col>
<Col span={8} sm={24} md={8}>
<Statistic
title="Peak viewer count"
value={sessionPeakViewerCount}
prefix={<UserOutlined />}
/>
</Col>
</Row>
</Card>
</div>
<div className="section stream-details-section">
<div className="details outbound-details">{videoQualitySettings}</div>
<Row gutter={[16, 16]} className="section stream-details-section">
<Col className="outbound-details" span={12} sm={24} md={24} lg={12}>
<Card size="small" title="Outbound Stream Details" type="inner">
{videoQualitySettings}
</Card>
</Col>
<div className="details other-details">
<Card title="Inbound Stream Details" type="inner">
<StatisticItem
<Col className="inbound-details" span={12} sm={24} md={24} lg={12}>
<Card size="small" title="Inbound Stream Details" type="inner">
<Statistic
className="stream-details-item"
title="Input"
value={`${encoder} ${formatIPAddress(remoteAddr)}`}
prefix={null}
/>
<StatisticItem
<Statistic
className="stream-details-item"
title="Inbound Video Stream"
value={streamDetails}
formatter={streamDetailsFormatter}
prefix={null}
/>
<StatisticItem
<Statistic
className="stream-details-item"
title="Inbound Audio Stream"
value={streamAudioDetailString}
prefix={null}
/>
</Card>
<div className="server-detail">
<Card title="Server Config" type="inner">
<StatisticItem
title="Directory registration enabled"
value={configData.yp.enabled.toString()}
prefix={null}
/>
</Card>
</div>
</div>
</div>
</Col>
</Row>
</div>
<br />
<LogTable logs={logsData} pageSize={5} />
</div>
);

17
web/pages/offline-notice.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import Link from 'next/link';
import { Result, Card } from 'antd';
import { Result, Card, Row, Col } from 'antd';
import {
MessageTwoTone,
QuestionCircleTwoTone,
@ -55,22 +55,23 @@ export default function Offline({ logs = [] }) { @@ -55,22 +55,23 @@ export default function Offline({ logs = [] }) {
return (
<>
<div className="offline-content">
<div className="logo-section">
<Row gutter={[16, 16]} className="offline-content">
<Col span={12} xs={24} sm={24} md={24} lg={12} className="logo-section">
<Result
icon={<OwncastLogo />}
title="No stream is active."
subTitle="You should start one."
/>
</div>
<div className="list-section">
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={12} className="list-section">
{data.map(item => (
<Card key={item.title}>
<Card key={item.title} size="small" bordered={false}>
<Meta avatar={item.icon} title={item.title} description={item.content} />
</Card>
))}
</div>
</div>
</Col>
</Row>
<LogTable logs={logs} pageSize={5} />
</>
);

8
web/pages/viewer-info.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext } from 'react';
import { Table, Row } from 'antd';
import { Table, Row, Typography } from 'antd';
import { formatDistanceToNow } from 'date-fns';
import { UserOutlined } from '@ant-design/icons';
import { SortOrder } from 'antd/lib/table/interface';
@ -94,7 +94,9 @@ export default function ViewersOverTime() { @@ -94,7 +94,9 @@ export default function ViewersOverTime() {
];
return (
<div>
<>
<Typography.Title>Viewer Info</Typography.Title>
<br />
<Row gutter={[16, 16]} justify="space-around">
{online && (
<StatisticItem
@ -117,6 +119,6 @@ export default function ViewersOverTime() { @@ -117,6 +119,6 @@ export default function ViewersOverTime() {
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
{online && <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} />}
</div>
</>
);
}

54
web/styles/ant-overrides.scss

@ -9,6 +9,9 @@ @@ -9,6 +9,9 @@
.ant-card,
.ant-collapse,
.ant-collapse-content,
.ant-statistic,
.ant-statistic-title,
.ant-statistic-content,
.ant-table,
.ant-table-thead > tr > th,
.ant-table-small .ant-table-thead > tr > th,
@ -187,12 +190,10 @@ h3.ant-typography { @@ -187,12 +190,10 @@ h3.ant-typography {
.ant-card-meta-description {
color: var(--white-75);
}
.ant-card {
.ant-statistic,
.ant-statistic-title,
.ant-statistic-content {
color: var(--default-text-color);
}
.ant-card-type-inner .ant-card-head {
background-color: var(--black);
color: var(--white-88);
border-color: var(--white-25);
}
@ -262,7 +263,6 @@ textarea.ant-input { @@ -262,7 +263,6 @@ textarea.ant-input {
&:hover,
&:focus {
background-color: var(--button-focused);
border-color: var(--button-focused);
color: var(--white);
}
}
@ -273,11 +273,16 @@ textarea.ant-input { @@ -273,11 +273,16 @@ textarea.ant-input {
.ant-btn-primary:hover,
.ant-btn-primary:focus {
background-color: var(--button-focused);
border-color: var(--button-focused);
color: var(--white);
}
.ant-btn.ant-btn-primary:hover {
border-color: var(--white);
}
.ant-btn:focus,
.ant-btn-primary:focus {
border-color: var(--white);
}
.ant-btn-primary[disabled] {
background-color: var(--white-25);
border-color: var(--white-25);
@ -375,10 +380,30 @@ textarea.ant-input { @@ -375,10 +380,30 @@ textarea.ant-input {
// SELECT
.ant-select-dropdown {
background-color: var(--black);
}
.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
background-color: var(--black);
border-color: var(--owncast-purple-50);
}
.ant-select-arrow {
color: var(--owncast-purple);
}
.ant-select-selection-placeholder {
color: var(--owncast-purple-50);
}
.ant-select {
color: var(--white);
}
.ant-select-item {
background-color: var(--gray-dark);
color: var(--white-88);
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background-color: var(--gray);
color: var(--white-75);
}
// SLIDER
// .ant-slider-with-marks {
// margin-right: 2em;
@ -460,13 +485,12 @@ textarea.ant-input { @@ -460,13 +485,12 @@ textarea.ant-input {
// ANT TAGS
.ant-tag-orange {
background: #fa8c16;
color: #fff7e6;
border-color: #ffd591;
.ant-tag-red,
.ant-tag-orange,
.ant-tag-green,
.ant-tag-blue {
background-color: var(--black);
}

6
web/styles/config-public-details.scss

@ -69,4 +69,8 @@ @@ -69,4 +69,8 @@
height: 6em !important;
}
}
}
}
.other-field-container {
margin: .5em 0;
}

2
web/styles/config-socialhandles.scss

@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
flex-direction: row;
align-items: center;
justify-content: flex-start;
color: rgba(255,255,255,.85);
color: var(--white-75);
.option-icon {
height: 2em;

169
web/styles/home.scss

@ -1,121 +1,45 @@ @@ -1,121 +1,45 @@
.home-container {
max-width: 1000px;
.statistics-list {
li {
margin-left: -.5em;
}
}
.section {
margin: 1rem 0;
.ant-statistic-content {
font-size: 1rem;
}
}
.online-status-section {
> .ant-card {
box-shadow: 0px 1px 10px 2px rgba(0, 22, 40, 0.1);
}
.ant-card-head {
background-color: #40b246;
border-color: #ccc;
color:#fff;
@media (prefers-color-scheme: dark) {
background-color: #2a762e;
border-bottom-color: black;
}
margin-bottom: 1em;
.online-details-card {
border-color: var(--online-color);
}
.ant-card-head-title {
font-size: .88rem;
.ant-statistic {
text-align: center;
}
.ant-statistic-title {
font-size: .88rem;
}
.ant-card-body {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
.ant-statistic {
width: 30%;
text-align: center;
margin: 0 1rem;
}
color: var(--white-50);
}
}
.ant-card-head {
color: var(--online-color);
}
.stream-details-section {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
width: 100%;
.details {
width: 49%;
> .ant-card {
margin-bottom: 1rem;
}
.ant-card-head {
background-color: #ccd;
color: black;
@media (prefers-color-scheme: dark) {
background-color: #000;
color: #ccd;
}
}
}
.server-detail {
.ant-card-body {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
.ant-card {
width: 45%;
text-align: center;
}
}
.ant-card-head {
background-color: #669;
color: #fff;
}
.stream-details-item-container {
margin: 1em 0;
&:first-of-type {
margin-top: 0;
}
}
@media (max-width: 800px) {
.online-status-section{
.ant-card-body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
.ant-statistic {
width: auto;
text-align: left;
margin: 1em;
}
}
.ant-statistic.stream-details-item {
background-color: var(--black-50);
padding: 1em;
.ant-statistic-title {
color: var(--blue);
}
.ant-statistic-content {
font-size: 1.25em;
white-space: nowrap;
}
}
.stream-details-section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
width: 100%;
.details {
width: 100%;
}
.outbound-details,
.inbound-details {
>.ant-card-bordered {
border-color: rgba(255,255,255,.1);
}
}
}
@ -124,14 +48,7 @@ @@ -124,14 +48,7 @@
.offline-content {
max-width: 1000px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
width: 100%;
.logo-section {
width: 50%;
.ant-result-title {
font-size: 2rem;
}
@ -144,36 +61,20 @@ @@ -144,36 +61,20 @@
}
}
.list-section {
width: 50%;
background-color: var(--container-bg-color-alt);
border-radius: var(--container-border-radius);
padding: 1em;
> .ant-card {
margin-bottom: 1rem;
.ant-card-head {
background-color: #dde;
}
.ant-card-head-title {
font-size: 1rem;
}
background-color: var(--black);
margin-bottom: 1em;
.ant-card-meta-avatar {
margin-top: .25rem;
svg {
height: 1.25rem;
width: 1.25rem;
height: 1.5em;
width: 1.5em;
}
}
.ant-card-body {
font-size: .88rem;
}
}
}
@media (max-width: 800px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
.logo-section,
.list-section {
width: 100%
}
}
}

16
web/styles/main-layout.scss

@ -84,7 +84,7 @@ @@ -84,7 +84,7 @@
}
}
}
.online {
&.online {
.online-status-indicator {
.status-icon {
svg {
@ -92,6 +92,7 @@ @@ -92,6 +92,7 @@
}
}
.status-label {
white-space: nowrap;
color: var(--online-color);
}
}
@ -111,8 +112,21 @@ @@ -111,8 +112,21 @@
align-items: center;
margin-bottom: 0;
.ant-input-affix-wrapper {
border-color: var(--owncast-purple-50);
}
input.ant-input {
&::placeholder {
color: var(--owncast-purple);
text-align: center;
}
}
.input-side {
width: 400px;
@media (max-width: 800px) {
width: auto;
}
}
.label-side {

Loading…
Cancel
Save